schemathesis 4.0.0a10__py3-none-any.whl → 4.0.0a12__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 (111) hide show
  1. schemathesis/__init__.py +29 -30
  2. schemathesis/auths.py +65 -24
  3. schemathesis/checks.py +73 -39
  4. schemathesis/cli/commands/__init__.py +51 -3
  5. schemathesis/cli/commands/data.py +10 -0
  6. schemathesis/cli/commands/run/__init__.py +163 -274
  7. schemathesis/cli/commands/run/context.py +8 -4
  8. schemathesis/cli/commands/run/events.py +11 -1
  9. schemathesis/cli/commands/run/executor.py +70 -78
  10. schemathesis/cli/commands/run/filters.py +15 -165
  11. schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
  12. schemathesis/cli/commands/run/handlers/junitxml.py +5 -4
  13. schemathesis/cli/commands/run/handlers/output.py +195 -121
  14. schemathesis/cli/commands/run/loaders.py +35 -50
  15. schemathesis/cli/commands/run/validation.py +52 -162
  16. schemathesis/cli/core.py +5 -3
  17. schemathesis/cli/ext/fs.py +7 -5
  18. schemathesis/cli/ext/options.py +0 -21
  19. schemathesis/config/__init__.py +189 -0
  20. schemathesis/config/_auth.py +51 -0
  21. schemathesis/config/_checks.py +268 -0
  22. schemathesis/config/_diff_base.py +99 -0
  23. schemathesis/config/_env.py +21 -0
  24. schemathesis/config/_error.py +156 -0
  25. schemathesis/config/_generation.py +149 -0
  26. schemathesis/config/_health_check.py +24 -0
  27. schemathesis/config/_operations.py +327 -0
  28. schemathesis/config/_output.py +171 -0
  29. schemathesis/config/_parameters.py +19 -0
  30. schemathesis/config/_phases.py +187 -0
  31. schemathesis/config/_projects.py +523 -0
  32. schemathesis/config/_rate_limit.py +17 -0
  33. schemathesis/config/_report.py +120 -0
  34. schemathesis/config/_validator.py +9 -0
  35. schemathesis/config/_warnings.py +25 -0
  36. schemathesis/config/schema.json +885 -0
  37. schemathesis/core/__init__.py +2 -0
  38. schemathesis/core/compat.py +16 -9
  39. schemathesis/core/errors.py +24 -4
  40. schemathesis/core/failures.py +6 -7
  41. schemathesis/core/hooks.py +20 -0
  42. schemathesis/core/output/__init__.py +14 -37
  43. schemathesis/core/output/sanitization.py +3 -146
  44. schemathesis/core/transport.py +36 -1
  45. schemathesis/core/validation.py +16 -0
  46. schemathesis/engine/__init__.py +2 -4
  47. schemathesis/engine/context.py +42 -43
  48. schemathesis/engine/core.py +7 -5
  49. schemathesis/engine/errors.py +60 -1
  50. schemathesis/engine/events.py +10 -2
  51. schemathesis/engine/phases/__init__.py +10 -0
  52. schemathesis/engine/phases/probes.py +11 -8
  53. schemathesis/engine/phases/stateful/__init__.py +2 -1
  54. schemathesis/engine/phases/stateful/_executor.py +104 -46
  55. schemathesis/engine/phases/stateful/context.py +2 -2
  56. schemathesis/engine/phases/unit/__init__.py +23 -15
  57. schemathesis/engine/phases/unit/_executor.py +110 -21
  58. schemathesis/engine/phases/unit/_pool.py +1 -1
  59. schemathesis/errors.py +2 -0
  60. schemathesis/filters.py +2 -3
  61. schemathesis/generation/__init__.py +5 -33
  62. schemathesis/generation/case.py +6 -3
  63. schemathesis/generation/coverage.py +154 -124
  64. schemathesis/generation/hypothesis/builder.py +70 -20
  65. schemathesis/generation/meta.py +3 -3
  66. schemathesis/generation/metrics.py +93 -0
  67. schemathesis/generation/modes.py +0 -8
  68. schemathesis/generation/overrides.py +37 -1
  69. schemathesis/generation/stateful/__init__.py +4 -0
  70. schemathesis/generation/stateful/state_machine.py +9 -1
  71. schemathesis/graphql/loaders.py +159 -16
  72. schemathesis/hooks.py +62 -35
  73. schemathesis/openapi/checks.py +12 -8
  74. schemathesis/openapi/generation/filters.py +10 -8
  75. schemathesis/openapi/loaders.py +142 -17
  76. schemathesis/pytest/lazy.py +2 -5
  77. schemathesis/pytest/loaders.py +24 -0
  78. schemathesis/pytest/plugin.py +33 -2
  79. schemathesis/schemas.py +21 -66
  80. schemathesis/specs/graphql/scalars.py +37 -3
  81. schemathesis/specs/graphql/schemas.py +23 -18
  82. schemathesis/specs/openapi/_hypothesis.py +26 -28
  83. schemathesis/specs/openapi/checks.py +37 -36
  84. schemathesis/specs/openapi/examples.py +4 -3
  85. schemathesis/specs/openapi/formats.py +32 -5
  86. schemathesis/specs/openapi/media_types.py +44 -1
  87. schemathesis/specs/openapi/negative/__init__.py +2 -2
  88. schemathesis/specs/openapi/patterns.py +46 -16
  89. schemathesis/specs/openapi/references.py +2 -3
  90. schemathesis/specs/openapi/schemas.py +19 -22
  91. schemathesis/specs/openapi/stateful/__init__.py +12 -6
  92. schemathesis/transport/__init__.py +54 -16
  93. schemathesis/transport/prepare.py +38 -13
  94. schemathesis/transport/requests.py +12 -9
  95. schemathesis/transport/wsgi.py +11 -12
  96. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/METADATA +50 -97
  97. schemathesis-4.0.0a12.dist-info/RECORD +164 -0
  98. schemathesis/cli/commands/run/checks.py +0 -79
  99. schemathesis/cli/commands/run/hypothesis.py +0 -78
  100. schemathesis/cli/commands/run/reports.py +0 -72
  101. schemathesis/cli/hooks.py +0 -36
  102. schemathesis/contrib/__init__.py +0 -9
  103. schemathesis/contrib/openapi/__init__.py +0 -9
  104. schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
  105. schemathesis/engine/config.py +0 -59
  106. schemathesis/experimental/__init__.py +0 -72
  107. schemathesis/generation/targets.py +0 -69
  108. schemathesis-4.0.0a10.dist-info/RECORD +0 -153
  109. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/WHEEL +0 -0
  110. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/entry_points.txt +0 -0
  111. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/licenses/LICENSE +0 -0
@@ -6,6 +6,7 @@ from os import PathLike
6
6
  from pathlib import Path
7
7
  from typing import IO, TYPE_CHECKING, Any, Callable, Dict, NoReturn, TypeVar, cast
8
8
 
9
+ from schemathesis.config import SchemathesisConfig
9
10
  from schemathesis.core.errors import LoaderError, LoaderErrorKind
10
11
  from schemathesis.core.loaders import load_from_url, prepare_request_kwargs, raise_for_status, require_relative_url
11
12
  from schemathesis.hooks import HookContext, dispatch
@@ -17,16 +18,52 @@ if TYPE_CHECKING:
17
18
  from schemathesis.specs.graphql.schemas import GraphQLSchema
18
19
 
19
20
 
20
- def from_asgi(path: str, app: Any, **kwargs: Any) -> GraphQLSchema:
21
+ def from_asgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> GraphQLSchema:
22
+ """Load GraphQL schema from an ASGI application via introspection.
23
+
24
+ Args:
25
+ path: Relative URL path to the GraphQL endpoint (e.g., "/graphql")
26
+ app: ASGI application instance
27
+ config: Custom configuration. If `None`, uses auto-discovered config
28
+ **kwargs: Additional request parameters passed to the ASGI test client.
29
+
30
+ Example:
31
+ ```python
32
+ from fastapi import FastAPI
33
+ import schemathesis
34
+
35
+ app = FastAPI()
36
+ schema = schemathesis.graphql.from_asgi("/graphql", app)
37
+ ```
38
+
39
+ """
21
40
  require_relative_url(path)
22
41
  kwargs.setdefault("json", {"query": get_introspection_query()})
23
42
  client = asgi.get_client(app)
24
43
  response = load_from_url(client.post, url=path, **kwargs)
25
44
  schema = extract_schema_from_response(response, lambda r: r.json())
26
- return from_dict(schema=schema).configure(app=app, location=path)
45
+ return from_dict(schema=schema, config=config).configure(app=app, location=path)
46
+
47
+
48
+ def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> GraphQLSchema:
49
+ """Load GraphQL schema from a WSGI application via introspection.
50
+
51
+ Args:
52
+ path: Relative URL path to the GraphQL endpoint (e.g., "/graphql")
53
+ app: WSGI application instance
54
+ config: Custom configuration. If `None`, uses auto-discovered config
55
+ **kwargs: Additional request parameters passed to the WSGI test client.
27
56
 
57
+ Example:
58
+ ```python
59
+ from flask import Flask
60
+ import schemathesis
28
61
 
29
- def from_wsgi(path: str, app: Any, **kwargs: Any) -> GraphQLSchema:
62
+ app = Flask(__name__)
63
+ schema = schemathesis.graphql.from_wsgi("/graphql", app)
64
+ ```
65
+
66
+ """
30
67
  require_relative_url(path)
31
68
  prepare_request_kwargs(kwargs)
32
69
  kwargs.setdefault("json", {"query": get_introspection_query()})
@@ -34,27 +71,97 @@ def from_wsgi(path: str, app: Any, **kwargs: Any) -> GraphQLSchema:
34
71
  response = client.post(path=path, **kwargs)
35
72
  raise_for_status(response)
36
73
  schema = extract_schema_from_response(response, lambda r: r.json)
37
- return from_dict(schema=schema).configure(app=app, location=path)
74
+ return from_dict(schema=schema, config=config).configure(app=app, location=path)
75
+
76
+
77
+ def from_url(
78
+ url: str, *, config: SchemathesisConfig | None = None, wait_for_schema: float | None = None, **kwargs: Any
79
+ ) -> GraphQLSchema:
80
+ """Load GraphQL schema from a URL via introspection query.
81
+
82
+ Args:
83
+ url: Full URL to the GraphQL endpoint
84
+ config: Custom configuration. If `None`, uses auto-discovered config
85
+ wait_for_schema: Maximum time in seconds to wait for schema availability
86
+ **kwargs: Additional parameters passed to `requests.post()` (headers, timeout, auth, etc.).
38
87
 
88
+ Example:
89
+ ```python
90
+ import schemathesis
39
91
 
40
- def from_url(url: str, *, wait_for_schema: float | None = None, **kwargs: Any) -> GraphQLSchema:
41
- """Load from URL."""
92
+ # Basic usage
93
+ schema = schemathesis.graphql.from_url("https://api.example.com/graphql")
94
+
95
+ # With authentication and timeout
96
+ schema = schemathesis.graphql.from_url(
97
+ "https://api.example.com/graphql",
98
+ headers={"Authorization": "Bearer token"},
99
+ timeout=30,
100
+ wait_for_schema=10.0
101
+ )
102
+ ```
103
+
104
+ """
42
105
  import requests
43
106
 
44
107
  kwargs.setdefault("json", {"query": get_introspection_query()})
45
108
  response = load_from_url(requests.post, url=url, wait_for_schema=wait_for_schema, **kwargs)
46
109
  schema = extract_schema_from_response(response, lambda r: r.json())
47
- return from_dict(schema).configure(location=url)
110
+ return from_dict(schema, config=config).configure(location=url)
111
+
48
112
 
113
+ def from_path(
114
+ path: PathLike | str, *, config: SchemathesisConfig | None = None, encoding: str = "utf-8"
115
+ ) -> GraphQLSchema:
116
+ """Load GraphQL schema from a filesystem path.
49
117
 
50
- def from_path(path: PathLike | str, *, encoding: str = "utf-8") -> GraphQLSchema:
51
- """Load from a filesystem path."""
118
+ Args:
119
+ path: File path to the GraphQL schema file (.graphql, .gql)
120
+ config: Custom configuration. If `None`, uses auto-discovered config
121
+ encoding: Text encoding for reading the file
122
+
123
+ Example:
124
+ ```python
125
+ import schemathesis
126
+
127
+ # Load from GraphQL SDL file
128
+ schema = schemathesis.graphql.from_path("./schema.graphql")
129
+ ```
130
+
131
+ """
52
132
  with open(path, encoding=encoding) as file:
53
- return from_file(file=file).configure(location=Path(path).absolute().as_uri())
133
+ return from_file(file=file, config=config).configure(location=Path(path).absolute().as_uri())
134
+
135
+
136
+ def from_file(file: IO[str] | str, *, config: SchemathesisConfig | None = None) -> GraphQLSchema:
137
+ """Load GraphQL schema from a file-like object or string.
138
+
139
+ Args:
140
+ file: File-like object or raw string containing GraphQL SDL
141
+ config: Custom configuration. If `None`, uses auto-discovered config
142
+
143
+ Example:
144
+ ```python
145
+ import schemathesis
54
146
 
147
+ # From GraphQL SDL string
148
+ schema_sdl = '''
149
+ type Query {
150
+ user(id: ID!): User
151
+ }
152
+ type User {
153
+ id: ID!
154
+ name: String!
155
+ }
156
+ '''
157
+ schema = schemathesis.graphql.from_file(schema_sdl)
55
158
 
56
- def from_file(file: IO[str] | str) -> GraphQLSchema:
57
- """Load from file-like object or string."""
159
+ # From file object
160
+ with open("schema.graphql") as f:
161
+ schema = schemathesis.graphql.from_file(f)
162
+ ```
163
+
164
+ """
58
165
  import graphql
59
166
 
60
167
  if isinstance(file, str):
@@ -78,18 +185,54 @@ def from_file(file: IO[str] | str) -> GraphQLSchema:
78
185
  _on_invalid_schema(exc)
79
186
  except json.JSONDecodeError:
80
187
  _on_invalid_schema(exc, extras=[entry for entry in str(exc).splitlines() if entry])
81
- return from_dict(schema)
188
+ return from_dict(schema, config=config)
189
+
190
+
191
+ def from_dict(schema: dict[str, Any], *, config: SchemathesisConfig | None = None) -> GraphQLSchema:
192
+ """Load GraphQL schema from a dictionary containing introspection result.
193
+
194
+ Args:
195
+ schema: Dictionary containing GraphQL introspection result or wrapped in 'data' key
196
+ config: Custom configuration. If `None`, uses auto-discovered config
82
197
 
198
+ Example:
199
+ ```python
200
+ import schemathesis
83
201
 
84
- def from_dict(schema: dict[str, Any]) -> GraphQLSchema:
85
- """Base loader that others build upon."""
202
+ # From introspection result
203
+ introspection = {
204
+ "__schema": {
205
+ "types": [...],
206
+ "queryType": {"name": "Query"},
207
+ # ... rest of introspection result
208
+ }
209
+ }
210
+ schema = schemathesis.graphql.from_dict(introspection)
211
+
212
+ # From GraphQL response format (with 'data' wrapper)
213
+ response_data = {
214
+ "data": {
215
+ "__schema": {
216
+ "types": [...],
217
+ "queryType": {"name": "Query"}
218
+ }
219
+ }
220
+ }
221
+ schema = schemathesis.graphql.from_dict(response_data)
222
+ ```
223
+
224
+ """
86
225
  from schemathesis.specs.graphql.schemas import GraphQLSchema
87
226
 
88
227
  if "data" in schema:
89
228
  schema = schema["data"]
90
229
  hook_context = HookContext()
91
230
  dispatch("before_load_schema", hook_context, schema)
92
- instance = GraphQLSchema(schema)
231
+
232
+ if config is None:
233
+ config = SchemathesisConfig.discover()
234
+ project_config = config.projects.get(schema)
235
+ instance = GraphQLSchema(schema, config=project_config)
93
236
  dispatch("after_load_schema", hook_context, instance)
94
237
  return instance
95
238
 
schemathesis/hooks.py CHANGED
@@ -37,13 +37,15 @@ class RegisteredHook:
37
37
 
38
38
  @dataclass
39
39
  class HookContext:
40
- """A context that is passed to some hook functions.
40
+ """A context that is passed to some hook functions."""
41
41
 
42
- :ivar Optional[APIOperation] operation: API operation that is currently being processed.
43
- Might be absent in some cases.
44
- """
42
+ operation: APIOperation | None
43
+ """API operation that is currently being processed."""
44
+
45
+ __slots__ = ("operation",)
45
46
 
46
- operation: APIOperation | None = None
47
+ def __init__(self, *, operation: APIOperation | None = None) -> None:
48
+ self.operation = operation
47
49
 
48
50
 
49
51
  def to_filterable_hook(dispatcher: HookDispatcher) -> Callable:
@@ -110,48 +112,29 @@ class HookDispatcher:
110
112
  _specs: ClassVar[dict[str, RegisteredHook]] = {}
111
113
 
112
114
  def __post_init__(self) -> None:
113
- self.register = to_filterable_hook(self) # type: ignore[method-assign]
114
-
115
- def register(self, hook: str | Callable) -> Callable:
116
- """Register a new hook.
117
-
118
- :param hook: Either a hook function or a string.
119
-
120
- Can be used as a decorator in two forms.
121
- Without arguments for registering hooks and autodetecting their names:
122
-
123
- .. code-block:: python
124
-
125
- @schemathesis.hook
126
- def before_generate_query(context, strategy):
127
- ...
128
-
129
- With a hook name as the first argument:
130
-
131
- .. code-block:: python
115
+ self.hook = to_filterable_hook(self) # type: ignore[method-assign]
132
116
 
133
- @schemathesis.hook("before_generate_query")
134
- def hook(context, strategy):
135
- ...
136
- """
117
+ def hook(self, hook: str | Callable) -> Callable:
137
118
  raise NotImplementedError
138
119
 
139
120
  def apply(self, hook: Callable, *, name: str | None = None) -> Callable[[Callable], Callable]:
140
121
  """Register hook to run only on one test function.
141
122
 
142
- :param hook: A hook function.
143
- :param Optional[str] name: A hook name.
144
-
145
- .. code-block:: python
123
+ Args:
124
+ hook: A hook function.
125
+ name: A hook name.
146
126
 
147
- def before_generate_query(context, strategy):
127
+ Example:
128
+ ```python
129
+ def filter_query(ctx, value):
148
130
  ...
149
131
 
150
132
 
151
- @schema.hooks.apply(before_generate_query)
133
+ @schema.hooks.apply(filter_query)
152
134
  @schema.parametrize()
153
135
  def test_api(case):
154
136
  ...
137
+ ```
155
138
 
156
139
  """
157
140
  if name is None:
@@ -391,6 +374,50 @@ def after_call(context: HookContext, case: Case, response: Response) -> None:
391
374
  GLOBAL_HOOK_DISPATCHER = HookDispatcher(scope=HookScope.GLOBAL)
392
375
  dispatch = GLOBAL_HOOK_DISPATCHER.dispatch
393
376
  get_all_by_name = GLOBAL_HOOK_DISPATCHER.get_all_by_name
394
- register = GLOBAL_HOOK_DISPATCHER.register
395
377
  unregister = GLOBAL_HOOK_DISPATCHER.unregister
396
378
  unregister_all = GLOBAL_HOOK_DISPATCHER.unregister_all
379
+
380
+
381
+ def hook(hook: str | Callable) -> Callable:
382
+ """Register a new hook.
383
+
384
+ Args:
385
+ hook: Either a hook function (autodetecting its name) or a string matching one of the supported hook names.
386
+
387
+ Example:
388
+ Can be used as a decorator in two ways:
389
+
390
+ 1. Without arguments (auto-detect the hook name from the function name):
391
+
392
+ ```python
393
+ @schemathesis.hook
394
+ def filter_query(ctx, query):
395
+ \"\"\"Skip cases where query is None or invalid\"\"\"
396
+ return query and "user_id" in query
397
+
398
+ @schemathesis.hook
399
+ def before_call(ctx, case):
400
+ \"\"\"Modify headers before sending each request\"\"\"
401
+ if case.headers is None:
402
+ case.headers = {}
403
+ case.headers["X-Test-Mode"] = "true"
404
+ return None
405
+ ```
406
+
407
+ 2. With an explicit hook name as the first argument:
408
+
409
+ ```python
410
+ @schemathesis.hook("map_headers")
411
+ def add_custom_header(ctx, headers):
412
+ \"\"\"Inject a test header into every request\"\"\"
413
+ if headers is None:
414
+ headers = {}
415
+ headers["X-Custom"] = "value"
416
+ return headers
417
+ ```
418
+
419
+ """
420
+ return GLOBAL_HOOK_DISPATCHER.hook(hook)
421
+
422
+
423
+ hook.__dict__ = GLOBAL_HOOK_DISPATCHER.hook.__dict__
@@ -4,8 +4,9 @@ import textwrap
4
4
  from dataclasses import dataclass, field
5
5
  from typing import TYPE_CHECKING, Any
6
6
 
7
+ from schemathesis.config import OutputConfig
7
8
  from schemathesis.core.failures import Failure, Severity
8
- from schemathesis.core.output import OutputConfig, truncate_json
9
+ from schemathesis.core.output import truncate_json
9
10
 
10
11
  if TYPE_CHECKING:
11
12
  from jsonschema import ValidationError
@@ -141,11 +142,14 @@ class JsonSchemaError(Failure):
141
142
  title: str = "Response violates schema",
142
143
  operation: str,
143
144
  exc: ValidationError,
144
- output_config: OutputConfig | None = None,
145
+ config: OutputConfig | None = None,
145
146
  ) -> JsonSchemaError:
146
- output_config = OutputConfig.from_parent(output_config, max_lines=20)
147
- schema = textwrap.indent(truncate_json(exc.schema, config=output_config), prefix=" ")
148
- value = textwrap.indent(truncate_json(exc.instance, config=output_config), prefix=" ")
147
+ schema = textwrap.indent(
148
+ truncate_json(exc.schema, config=config or OutputConfig(), max_lines=20), prefix=" "
149
+ )
150
+ value = textwrap.indent(
151
+ truncate_json(exc.instance, config=config or OutputConfig(), max_lines=20), prefix=" "
152
+ )
149
153
  schema_path = list(exc.absolute_schema_path)
150
154
  if len(schema_path) > 1:
151
155
  # Exclude the last segment, which is already in the schema
@@ -336,7 +340,7 @@ class IgnoredAuth(Failure):
336
340
  class AcceptedNegativeData(Failure):
337
341
  """Response with negative data was accepted."""
338
342
 
339
- __slots__ = ("operation", "message", "status_code", "allowed_statuses", "title", "case_id", "severity")
343
+ __slots__ = ("operation", "message", "status_code", "expected_statuses", "title", "case_id", "severity")
340
344
 
341
345
  def __init__(
342
346
  self,
@@ -344,14 +348,14 @@ class AcceptedNegativeData(Failure):
344
348
  operation: str,
345
349
  message: str,
346
350
  status_code: int,
347
- allowed_statuses: list[str],
351
+ expected_statuses: list[str],
348
352
  title: str = "Accepted negative data",
349
353
  case_id: str | None = None,
350
354
  ) -> None:
351
355
  self.operation = operation
352
356
  self.message = message
353
357
  self.status_code = status_code
354
- self.allowed_statuses = allowed_statuses
358
+ self.expected_statuses = expected_statuses
355
359
  self.title = title
356
360
  self.case_id = case_id
357
361
  self.severity = Severity.MEDIUM
@@ -1,4 +1,5 @@
1
1
  from collections.abc import Mapping
2
+ from typing import Any
2
3
 
3
4
  from schemathesis.core import NOT_SET
4
5
  from schemathesis.core.validation import contains_unicode_surrogate_pair, has_invalid_characters, is_latin_1_encodable
@@ -21,14 +22,15 @@ def is_valid_path(parameters: dict[str, object]) -> bool:
21
22
  In this case one variable in the path template will be empty, which will lead to 404 in most of the cases.
22
23
  Because of it this case doesn't bring much value and might lead to false positives results of Schemathesis runs.
23
24
  """
24
- return not any(
25
- (
26
- value in ("/", "")
27
- or contains_unicode_surrogate_pair(value)
28
- or isinstance(value, str)
29
- and ("/" in value or "}" in value or "{" in value)
30
- )
31
- for value in parameters.values()
25
+ return not any(is_invalid_path_parameter(value) for value in parameters.values())
26
+
27
+
28
+ def is_invalid_path_parameter(value: Any) -> bool:
29
+ return (
30
+ value in ("/", "")
31
+ or contains_unicode_surrogate_pair(value)
32
+ or isinstance(value, str)
33
+ and ("/" in value or "}" in value or "{" in value)
32
34
  )
33
35
 
34
36
 
@@ -7,6 +7,7 @@ from os import PathLike
7
7
  from pathlib import Path
8
8
  from typing import IO, TYPE_CHECKING, Any, Mapping
9
9
 
10
+ from schemathesis.config import SchemathesisConfig
10
11
  from schemathesis.core import media_types
11
12
  from schemathesis.core.deserialization import deserialize_yaml
12
13
  from schemathesis.core.errors import LoaderError, LoaderErrorKind
@@ -18,16 +19,52 @@ if TYPE_CHECKING:
18
19
  from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
19
20
 
20
21
 
21
- def from_asgi(path: str, app: Any, **kwargs: Any) -> BaseOpenAPISchema:
22
+ def from_asgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> BaseOpenAPISchema:
23
+ """Load OpenAPI schema from an ASGI application.
24
+
25
+ Args:
26
+ path: Relative URL path to the OpenAPI schema endpoint (e.g., "/openapi.json")
27
+ app: ASGI application instance
28
+ config: Custom configuration. If `None`, uses auto-discovered config
29
+ **kwargs: Additional request parameters passed to the ASGI test client
30
+
31
+ Example:
32
+ ```python
33
+ from fastapi import FastAPI
34
+ import schemathesis
35
+
36
+ app = FastAPI()
37
+ schema = schemathesis.openapi.from_asgi("/openapi.json", app)
38
+ ```
39
+
40
+ """
22
41
  require_relative_url(path)
23
42
  client = asgi.get_client(app)
24
43
  response = load_from_url(client.get, url=path, **kwargs)
25
44
  content_type = detect_content_type(headers=response.headers, path=path)
26
45
  schema = load_content(response.text, content_type)
27
- return from_dict(schema=schema).configure(app=app, location=path)
46
+ return from_dict(schema=schema, config=config).configure(app=app, location=path)
47
+
48
+
49
+ def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> BaseOpenAPISchema:
50
+ """Load OpenAPI schema from a WSGI application.
51
+
52
+ Args:
53
+ path: Relative URL path to the OpenAPI schema endpoint (e.g., "/openapi.json")
54
+ app: WSGI application instance
55
+ config: Custom configuration. If `None`, uses auto-discovered config
56
+ **kwargs: Additional request parameters passed to the WSGI test client
57
+
58
+ Example:
59
+ ```python
60
+ from flask import Flask
61
+ import schemathesis
28
62
 
63
+ app = Flask(__name__)
64
+ schema = schemathesis.openapi.from_wsgi("/openapi.json", app)
65
+ ```
29
66
 
30
- def from_wsgi(path: str, app: Any, **kwargs: Any) -> BaseOpenAPISchema:
67
+ """
31
68
  require_relative_url(path)
32
69
  prepare_request_kwargs(kwargs)
33
70
  client = wsgi.get_client(app)
@@ -35,29 +72,94 @@ def from_wsgi(path: str, app: Any, **kwargs: Any) -> BaseOpenAPISchema:
35
72
  raise_for_status(response)
36
73
  content_type = detect_content_type(headers=response.headers, path=path)
37
74
  schema = load_content(response.text, content_type)
38
- return from_dict(schema=schema).configure(app=app, location=path)
75
+ return from_dict(schema=schema, config=config).configure(app=app, location=path)
39
76
 
40
77
 
41
- def from_url(url: str, *, wait_for_schema: float | None = None, **kwargs: Any) -> BaseOpenAPISchema:
42
- """Load from URL."""
78
+ def from_url(
79
+ url: str, *, config: SchemathesisConfig | None = None, wait_for_schema: float | None = None, **kwargs: Any
80
+ ) -> BaseOpenAPISchema:
81
+ """Load OpenAPI schema from a URL.
82
+
83
+ Args:
84
+ url: Full URL to the OpenAPI schema
85
+ config: Custom configuration. If `None`, uses auto-discovered config
86
+ wait_for_schema: Maximum time in seconds to wait for schema availability
87
+ **kwargs: Additional parameters passed to `requests.get()` (headers, timeout, auth, etc.)
88
+
89
+ Example:
90
+ ```python
91
+ import schemathesis
92
+
93
+ # Basic usage
94
+ schema = schemathesis.openapi.from_url("https://api.example.com/openapi.json")
95
+
96
+ # With authentication and timeout
97
+ schema = schemathesis.openapi.from_url(
98
+ "https://api.example.com/openapi.json",
99
+ headers={"Authorization": "Bearer token"},
100
+ timeout=30,
101
+ wait_for_schema=10.0
102
+ )
103
+ ```
104
+
105
+ """
43
106
  import requests
44
107
 
45
108
  response = load_from_url(requests.get, url=url, wait_for_schema=wait_for_schema, **kwargs)
46
109
  content_type = detect_content_type(headers=response.headers, path=url)
47
110
  schema = load_content(response.text, content_type)
48
- return from_dict(schema=schema).configure(location=url)
111
+ return from_dict(schema=schema, config=config).configure(location=url)
112
+
49
113
 
114
+ def from_path(
115
+ path: PathLike | str, *, config: SchemathesisConfig | None = None, encoding: str = "utf-8"
116
+ ) -> BaseOpenAPISchema:
117
+ """Load OpenAPI schema from a filesystem path.
50
118
 
51
- def from_path(path: PathLike | str, *, encoding: str = "utf-8") -> BaseOpenAPISchema:
52
- """Load from a filesystem path."""
119
+ Args:
120
+ path: File path to the OpenAPI schema (supports JSON / YAML)
121
+ config: Custom configuration. If `None`, uses auto-discovered config
122
+ encoding: Text encoding for reading the file
123
+
124
+ Example:
125
+ ```python
126
+ import schemathesis
127
+
128
+ # Load from file
129
+ schema = schemathesis.openapi.from_path("./specs/openapi.yaml")
130
+
131
+ # With custom encoding
132
+ schema = schemathesis.openapi.from_path("./specs/openapi.json", encoding="cp1252")
133
+ ```
134
+
135
+ """
53
136
  with open(path, encoding=encoding) as file:
54
137
  content_type = detect_content_type(headers=None, path=str(path))
55
138
  schema = load_content(file.read(), content_type)
56
- return from_dict(schema=schema).configure(location=Path(path).absolute().as_uri())
139
+ return from_dict(schema=schema, config=config).configure(location=Path(path).absolute().as_uri())
140
+
141
+
142
+ def from_file(file: IO[str] | str, *, config: SchemathesisConfig | None = None) -> BaseOpenAPISchema:
143
+ """Load OpenAPI schema from a file-like object or string.
144
+
145
+ Args:
146
+ file: File-like object or raw string containing the OpenAPI schema
147
+ config: Custom configuration. If `None`, uses auto-discovered config
148
+
149
+ Example:
150
+ ```python
151
+ import schemathesis
57
152
 
153
+ # From string
154
+ schema_content = '{"openapi": "3.0.0", "info": {"title": "API"}}'
155
+ schema = schemathesis.openapi.from_file(schema_content)
58
156
 
59
- def from_file(file: IO[str] | str) -> BaseOpenAPISchema:
60
- """Load from file-like object or string."""
157
+ # From file object
158
+ with open("openapi.yaml") as f:
159
+ schema = schemathesis.openapi.from_file(f)
160
+ ```
161
+
162
+ """
61
163
  if isinstance(file, str):
62
164
  data = file
63
165
  else:
@@ -66,11 +168,30 @@ def from_file(file: IO[str] | str) -> BaseOpenAPISchema:
66
168
  schema = json.loads(data)
67
169
  except json.JSONDecodeError:
68
170
  schema = _load_yaml(data)
69
- return from_dict(schema)
171
+ return from_dict(schema, config=config)
172
+
173
+
174
+ def from_dict(schema: dict[str, Any], *, config: SchemathesisConfig | None = None) -> BaseOpenAPISchema:
175
+ """Load OpenAPI schema from a dictionary.
176
+
177
+ Args:
178
+ schema: Dictionary containing the parsed OpenAPI schema
179
+ config: Custom configuration. If `None`, uses auto-discovered config
70
180
 
181
+ Example:
182
+ ```python
183
+ import schemathesis
71
184
 
72
- def from_dict(schema: dict[str, Any]) -> BaseOpenAPISchema:
73
- """Base loader that others build upon."""
185
+ schema_dict = {
186
+ "openapi": "3.0.0",
187
+ "info": {"title": "My API", "version": "1.0.0"},
188
+ "paths": {"/users": {"get": {"responses": {"200": {"description": "OK"}}}}}
189
+ }
190
+
191
+ schema = schemathesis.openapi.from_dict(schema_dict)
192
+ ```
193
+
194
+ """
74
195
  from schemathesis.specs.openapi.schemas import OpenApi30, SwaggerV20
75
196
 
76
197
  if not isinstance(schema, dict):
@@ -78,8 +199,12 @@ def from_dict(schema: dict[str, Any]) -> BaseOpenAPISchema:
78
199
  hook_context = HookContext()
79
200
  dispatch("before_load_schema", hook_context, schema)
80
201
 
202
+ if config is None:
203
+ config = SchemathesisConfig.discover()
204
+ project_config = config.projects.get(schema)
205
+
81
206
  if "swagger" in schema:
82
- instance = SwaggerV20(schema)
207
+ instance = SwaggerV20(raw_schema=schema, config=project_config)
83
208
  elif "openapi" in schema:
84
209
  version = schema["openapi"]
85
210
  if not OPENAPI_VERSION_RE.match(version):
@@ -87,7 +212,7 @@ def from_dict(schema: dict[str, Any]) -> BaseOpenAPISchema:
87
212
  LoaderErrorKind.OPEN_API_UNSUPPORTED_VERSION,
88
213
  f"The provided schema uses Open API {version}, which is currently not supported.",
89
214
  )
90
- instance = OpenApi30(schema)
215
+ instance = OpenApi30(raw_schema=schema, config=project_config)
91
216
  else:
92
217
  raise LoaderError(
93
218
  LoaderErrorKind.OPEN_API_UNSPECIFIED_VERSION,