swaggerforge 0.2.0__tar.gz → 0.3.0__tar.gz

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 (36) hide show
  1. {swaggerforge-0.2.0/src/swaggerforge.egg-info → swaggerforge-0.3.0}/PKG-INFO +45 -2
  2. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/README.md +44 -1
  3. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/pyproject.toml +1 -1
  4. swaggerforge-0.3.0/src/swaggerforge/auth.py +143 -0
  5. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/cli.py +15 -4
  6. swaggerforge-0.3.0/src/swaggerforge/config.py +206 -0
  7. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/models.py +1 -0
  8. swaggerforge-0.3.0/src/swaggerforge/output.py +91 -0
  9. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/parser.py +36 -3
  10. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/template.py +12 -1
  11. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/templates/test_file.py.j2 +1 -1
  12. {swaggerforge-0.2.0 → swaggerforge-0.3.0/src/swaggerforge.egg-info}/PKG-INFO +45 -2
  13. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge.egg-info/SOURCES.txt +3 -0
  14. swaggerforge-0.3.0/tests/test_auth.py +142 -0
  15. swaggerforge-0.3.0/tests/test_config.py +332 -0
  16. swaggerforge-0.3.0/tests/test_e2e_auth.py +109 -0
  17. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/tests/test_generator.py +4 -4
  18. swaggerforge-0.3.0/tests/test_output.py +112 -0
  19. swaggerforge-0.3.0/tests/test_parser.py +200 -0
  20. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/tests/test_template.py +42 -1
  21. swaggerforge-0.2.0/src/swaggerforge/config.py +0 -102
  22. swaggerforge-0.2.0/src/swaggerforge/output.py +0 -53
  23. swaggerforge-0.2.0/tests/test_config.py +0 -120
  24. swaggerforge-0.2.0/tests/test_output.py +0 -46
  25. swaggerforge-0.2.0/tests/test_parser.py +0 -79
  26. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/LICENSE +0 -0
  27. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/setup.cfg +0 -0
  28. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/__init__.py +0 -0
  29. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/__main__.py +0 -0
  30. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/generator.py +0 -0
  31. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge/validator.py +0 -0
  32. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge.egg-info/dependency_links.txt +0 -0
  33. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge.egg-info/entry_points.txt +0 -0
  34. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge.egg-info/requires.txt +0 -0
  35. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/src/swaggerforge.egg-info/top_level.txt +0 -0
  36. {swaggerforge-0.2.0 → swaggerforge-0.3.0}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swaggerforge
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Automatic pytest test generation from OpenAPI (Swagger) specifications
5
5
  Author: Viktor Pylypenko
6
6
  License-Expression: MIT
@@ -48,6 +48,8 @@ established test-design techniques.
48
48
  - Deterministic output: the same specification always produces identical tests
49
49
  - Generated files use session-scoped pytest fixtures and run with no manual edits
50
50
  - Optional `swaggerforge.toml` config file for project-level defaults
51
+ - Authentication support: bearer, API key, and basic schemes, with
52
+ environment-variable substitution for secrets
51
53
 
52
54
  ## Requirements
53
55
 
@@ -109,6 +111,46 @@ Values given on the command line always take precedence over the config file.
109
111
  Without a `timeout`, generated tests place no time limit on requests -
110
112
  setting one makes test runs fail fast when the API is unreachable.
111
113
 
114
+ ## Authentication
115
+
116
+ When a specification declares security requirements, SwaggerForge embeds the
117
+ matching authentication headers into the generated tests. Credentials are
118
+ supplied through the config file, never taken from the specification itself.
119
+
120
+ ```toml
121
+ # swaggerforge.toml
122
+ [auth.bearer]
123
+ token = "${API_TOKEN}"
124
+
125
+ [auth.api_key]
126
+ value = "${API_KEY}"
127
+
128
+ [auth.basic]
129
+ username = "${API_USER}"
130
+ password = "${API_PASSWORD}"
131
+ ```
132
+
133
+ Three scheme kinds are supported, matching the OpenAPI security scheme types:
134
+
135
+ | Section | Applies to | Generated header |
136
+ |-----------------|---------------------------------------------|-------------------------------------------|
137
+ | `[auth.bearer]` | `http` bearer, and `oauth2` (pre-obtained token) | `Authorization: Bearer <token>` |
138
+ | `[auth.api_key]`| `apiKey` in header | the header named by the specification |
139
+ | `[auth.basic]` | `http` basic | `Authorization: Basic <base64>` |
140
+
141
+ For an API key, the header **name** comes from the specification's scheme
142
+ definition; the config supplies only the secret `value`.
143
+
144
+ **Environment variables.** Any auth value may reference an environment
145
+ variable with `${VAR}` syntax, so secrets stay out of the config file. A
146
+ referenced variable that is not set is an error.
147
+
148
+ **Unconfigured or unsupported schemes.** If a specification requires a scheme
149
+ whose credentials are not configured, SwaggerForge prints a warning and
150
+ generates the tests without that header, rather than failing. Schemes that are
151
+ not yet supported - API keys in a query string or cookie, and OpenID Connect -
152
+ are likewise skipped with a warning.
153
+
112
154
  ## How it works
113
155
 
114
156
  SwaggerForge runs a six-stage pipeline: the specification is validated, parsed
@@ -119,7 +161,8 @@ written to per-resource files.
119
161
  ## Limitations
120
162
 
121
163
  - Targets OpenAPI 3.x with JSON request bodies
122
- - Authentication is not yet handled (planned)
164
+ - Authentication covers bearer, API key (header), and basic schemes; API keys
165
+ in query or cookie, full OAuth2 flows, and OpenID Connect are not yet handled
123
166
  - Boundary tests require the specification to declare numeric/length constraints
124
167
 
125
168
  ## License
@@ -24,6 +24,8 @@ established test-design techniques.
24
24
  - Deterministic output: the same specification always produces identical tests
25
25
  - Generated files use session-scoped pytest fixtures and run with no manual edits
26
26
  - Optional `swaggerforge.toml` config file for project-level defaults
27
+ - Authentication support: bearer, API key, and basic schemes, with
28
+ environment-variable substitution for secrets
27
29
 
28
30
  ## Requirements
29
31
 
@@ -85,6 +87,46 @@ Values given on the command line always take precedence over the config file.
85
87
  Without a `timeout`, generated tests place no time limit on requests -
86
88
  setting one makes test runs fail fast when the API is unreachable.
87
89
 
90
+ ## Authentication
91
+
92
+ When a specification declares security requirements, SwaggerForge embeds the
93
+ matching authentication headers into the generated tests. Credentials are
94
+ supplied through the config file, never taken from the specification itself.
95
+
96
+ ```toml
97
+ # swaggerforge.toml
98
+ [auth.bearer]
99
+ token = "${API_TOKEN}"
100
+
101
+ [auth.api_key]
102
+ value = "${API_KEY}"
103
+
104
+ [auth.basic]
105
+ username = "${API_USER}"
106
+ password = "${API_PASSWORD}"
107
+ ```
108
+
109
+ Three scheme kinds are supported, matching the OpenAPI security scheme types:
110
+
111
+ | Section | Applies to | Generated header |
112
+ |-----------------|---------------------------------------------|-------------------------------------------|
113
+ | `[auth.bearer]` | `http` bearer, and `oauth2` (pre-obtained token) | `Authorization: Bearer <token>` |
114
+ | `[auth.api_key]`| `apiKey` in header | the header named by the specification |
115
+ | `[auth.basic]` | `http` basic | `Authorization: Basic <base64>` |
116
+
117
+ For an API key, the header **name** comes from the specification's scheme
118
+ definition; the config supplies only the secret `value`.
119
+
120
+ **Environment variables.** Any auth value may reference an environment
121
+ variable with `${VAR}` syntax, so secrets stay out of the config file. A
122
+ referenced variable that is not set is an error.
123
+
124
+ **Unconfigured or unsupported schemes.** If a specification requires a scheme
125
+ whose credentials are not configured, SwaggerForge prints a warning and
126
+ generates the tests without that header, rather than failing. Schemes that are
127
+ not yet supported - API keys in a query string or cookie, and OpenID Connect -
128
+ are likewise skipped with a warning.
129
+
88
130
  ## How it works
89
131
 
90
132
  SwaggerForge runs a six-stage pipeline: the specification is validated, parsed
@@ -95,7 +137,8 @@ written to per-resource files.
95
137
  ## Limitations
96
138
 
97
139
  - Targets OpenAPI 3.x with JSON request bodies
98
- - Authentication is not yet handled (planned)
140
+ - Authentication covers bearer, API key (header), and basic schemes; API keys
141
+ in query or cookie, full OAuth2 flows, and OpenID Connect are not yet handled
99
142
  - Boundary tests require the specification to declare numeric/length constraints
100
143
 
101
144
  ## License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swaggerforge"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Automatic pytest test generation from OpenAPI (Swagger) specifications"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,143 @@
1
+ """Resolution of authentication for generated tests.
2
+
3
+ Given an endpoint's declared security requirement, the specification's
4
+ security schemes, and the user's configured credentials, this module
5
+ decides which authentication headers to inject into the generated tests.
6
+
7
+ Supported schemes (v0.3.0): HTTP bearer, HTTP basic, and API key in a
8
+ header. OAuth2 is treated as bearer (the user supplies a pre-obtained
9
+ token). Other schemes - API key in query or cookie, OpenID Connect,
10
+ and any undefined scheme name - are skipped with a warning.
11
+ """
12
+
13
+ import base64
14
+ from typing import NamedTuple
15
+
16
+
17
+ class ResolvedAuth(NamedTuple):
18
+ """The outcome of resolving auth for one endpoint.
19
+
20
+ ``headers`` holds the authentication headers to inject (empty when no
21
+ auth applies). ``warning`` is a human-readable message when a declared
22
+ scheme was skipped, or None otherwise.
23
+ """
24
+
25
+ headers: dict
26
+ warning: str | None
27
+
28
+
29
+ _NO_AUTH = ResolvedAuth(headers={}, warning=None)
30
+
31
+
32
+ def resolve_auth(endpoint, security_schemes, auth_config):
33
+ """Resolve the authentication headers for a single endpoint.
34
+
35
+ Args:
36
+ endpoint: The Endpoint whose security requirement is resolved.
37
+ security_schemes: The specification's securitySchemes definitions.
38
+ auth_config: The AuthConfig carrying user-supplied credentials.
39
+
40
+ Returns:
41
+ A ResolvedAuth with the headers to inject and an optional warning.
42
+
43
+ Raises:
44
+ AuthError: If a supported scheme is required but its credentials
45
+ are not configured.
46
+ """
47
+ if not endpoint.security:
48
+ return _NO_AUTH
49
+
50
+ scheme_name = endpoint.security[0]
51
+ scheme = security_schemes.get(scheme_name)
52
+ if scheme is None:
53
+ return ResolvedAuth(
54
+ headers={},
55
+ warning=(
56
+ f"Security scheme '{scheme_name}' required by "
57
+ f"{endpoint.method.upper()} {endpoint.path} is not defined in "
58
+ f"the specification; skipping authentication for it."
59
+ ),
60
+ )
61
+
62
+ scheme_type = scheme.get("type")
63
+
64
+ if scheme_type == "oauth2":
65
+ return _resolve_bearer(scheme_name, auth_config)
66
+ if scheme_type == "http":
67
+ http_scheme = scheme.get("scheme", "").lower()
68
+ if http_scheme == "bearer":
69
+ return _resolve_bearer(scheme_name, auth_config)
70
+ if http_scheme == "basic":
71
+ return _resolve_basic(scheme_name, auth_config)
72
+ if scheme_type == "apiKey" and scheme.get("in") == "header":
73
+ return _resolve_api_key(scheme_name, scheme, auth_config)
74
+
75
+ return ResolvedAuth(
76
+ headers={},
77
+ warning=(
78
+ f"Security scheme '{scheme_name}' (type '{scheme_type}') is not "
79
+ f"supported in this version; skipping authentication for "
80
+ f"{endpoint.method.upper()} {endpoint.path}."
81
+ ),
82
+ )
83
+
84
+
85
+ def _resolve_bearer(scheme_name, auth_config):
86
+ """Build a bearer Authorization header from configured credentials."""
87
+ bearer = auth_config.bearer
88
+ if bearer is None or not bearer.token:
89
+ return ResolvedAuth(
90
+ headers={},
91
+ warning=(
92
+ f"Scheme '{scheme_name}' requires a bearer token, but none is "
93
+ f"configured; generating without authentication. Add a "
94
+ f"[auth.bearer] section with a 'token' to enable it."
95
+ ),
96
+ )
97
+ return ResolvedAuth(
98
+ headers={"Authorization": f"Bearer {bearer.token}"}, warning=None
99
+ )
100
+
101
+
102
+ def _resolve_basic(scheme_name, auth_config):
103
+ """Build a basic Authorization header from configured credentials."""
104
+ basic = auth_config.basic
105
+ if basic is None or not basic.username or not basic.password:
106
+ return ResolvedAuth(
107
+ headers={},
108
+ warning=(
109
+ f"Scheme '{scheme_name}' requires basic credentials, but the "
110
+ f"username and password are not both configured; generating "
111
+ f"without authentication. Add a [auth.basic] section with "
112
+ f"'username' and 'password' to enable it."
113
+ ),
114
+ )
115
+ raw = f"{basic.username}:{basic.password}".encode("utf-8")
116
+ encoded = base64.b64encode(raw).decode("ascii")
117
+ return ResolvedAuth(
118
+ headers={"Authorization": f"Basic {encoded}"}, warning=None
119
+ )
120
+
121
+
122
+ def _resolve_api_key(scheme_name, scheme, auth_config):
123
+ """Build an API-key header, taking the header name from the spec scheme."""
124
+ api_key = auth_config.api_key
125
+ if api_key is None or not api_key.value:
126
+ return ResolvedAuth(
127
+ headers={},
128
+ warning=(
129
+ f"Scheme '{scheme_name}' requires an API key, but none is "
130
+ f"configured; generating without authentication. Add a "
131
+ f"[auth.api_key] section with a 'value' to enable it."
132
+ ),
133
+ )
134
+ header_name = scheme.get("name")
135
+ if not header_name:
136
+ return ResolvedAuth(
137
+ headers={},
138
+ warning=(
139
+ f"API-key scheme '{scheme_name}' does not declare a header "
140
+ f"name; skipping authentication for it."
141
+ ),
142
+ )
143
+ return ResolvedAuth(headers={header_name: api_key.value}, warning=None)
@@ -68,7 +68,8 @@ def generate(spec, url, output, config_path):
68
68
  click.echo(f"API title: {spec_dict['info']['title']}")
69
69
 
70
70
  try:
71
- endpoints = parse_spec(spec)
71
+ parsed = parse_spec(spec)
72
+ endpoints = parsed.endpoints
72
73
  except SpecParseError as error:
73
74
  raise click.ClickException(str(error))
74
75
 
@@ -79,12 +80,22 @@ def generate(spec, url, output, config_path):
79
80
  click.echo(f"Generated {len(scenarios)} test scenarios.")
80
81
 
81
82
  try:
82
- written = write_test_files(scenarios, url, output, config.timeout)
83
+ result = write_test_files(
84
+ scenarios,
85
+ url,
86
+ output,
87
+ config.timeout,
88
+ security_schemes=parsed.security_schemes,
89
+ auth_config=config.auth,
90
+ )
83
91
  except OSError as error:
84
92
  raise click.ClickException(
85
93
  f"Could not write test files to '{output}': {error}"
86
94
  )
87
95
 
88
- click.echo(f"Wrote {len(written)} test file(s) to '{output}':")
89
- for path in written:
96
+ for warning in result.warnings:
97
+ click.echo(f"Warning: {warning}")
98
+
99
+ click.echo(f"Wrote {len(result.written)} test file(s) to '{output}':")
100
+ for path in result.written:
90
101
  click.echo(f" - {path}")
@@ -0,0 +1,206 @@
1
+ """Loading of user configuration from a swaggerforge.toml file.
2
+
3
+ This module reads optional user configuration for the generator. An
4
+ explicitly given path must exist and parse; an auto-discovered file
5
+ (./swaggerforge.toml in the working directory) may be absent, in which
6
+ case an empty configuration is returned.
7
+ """
8
+
9
+ import os
10
+ import re
11
+ from dataclasses import dataclass, field, fields
12
+ from pathlib import Path
13
+
14
+ try:
15
+ import tomllib # Python 3.11+
16
+ except ModuleNotFoundError:
17
+ import tomli as tomllib # Python 3.10 backport
18
+
19
+ DEFAULT_CONFIG_FILENAME = "swaggerforge.toml"
20
+
21
+ _KNOWN_KEYS = {
22
+ "timeout": int,
23
+ "output_dir": str,
24
+ "base_url": str,
25
+ }
26
+
27
+
28
+ class ConfigError(Exception):
29
+ """Raised when a config file is missing (if explicitly requested) or invalid."""
30
+
31
+
32
+ @dataclass
33
+ class BearerAuth:
34
+ """Bearer-token credentials for the API under test."""
35
+ token: str | None = None
36
+
37
+
38
+ @dataclass
39
+ class ApiKeyAuth:
40
+ """API-key credentials: the secret value (header name comes from the spec)."""
41
+ value: str | None = None
42
+
43
+
44
+ @dataclass
45
+ class BasicAuth:
46
+ """HTTP basic-auth credentials."""
47
+ username: str | None = None
48
+ password: str | None = None
49
+
50
+
51
+ @dataclass
52
+ class AuthConfig:
53
+ """Authentication credentials, keyed by scheme kind. All optional."""
54
+ bearer: BearerAuth | None = None
55
+ api_key: ApiKeyAuth | None = None
56
+ basic: BasicAuth | None = None
57
+
58
+
59
+ @dataclass
60
+ class Config:
61
+ """User configuration loaded from swaggerforge.toml.
62
+
63
+ A field value of None means the setting was not present in the file.
64
+ """
65
+ timeout: int | None = None
66
+ output_dir: str | None = None
67
+ base_url: str | None = None
68
+ auth: AuthConfig = field(default_factory=AuthConfig)
69
+
70
+
71
+ def load_config(config_path=None):
72
+ """Load configuration from a swaggerforge.toml file.
73
+
74
+ Args:
75
+ config_path: Explicit path to a config file. If given, the file
76
+ must exist and parse; otherwise ./swaggerforge.toml is
77
+ auto-discovered, and its absence yields an empty Config.
78
+
79
+ Returns:
80
+ A Config object with values from the file, or an empty Config
81
+ when no file was found during auto-discovery.
82
+
83
+ Raises:
84
+ ConfigError: If an explicitly given file is missing, if any file
85
+ contains invalid TOML, or if it contains unknown keys or
86
+ values of the wrong type.
87
+ """
88
+ if config_path is not None:
89
+ path = Path(config_path)
90
+ if not path.is_file():
91
+ raise ConfigError(f"Config file not found: '{config_path}'")
92
+ else:
93
+ path = Path(DEFAULT_CONFIG_FILENAME)
94
+ if not path.is_file():
95
+ return Config()
96
+
97
+ try:
98
+ raw = path.read_bytes()
99
+ # utf-8-sig strips a leading UTF-8 BOM if present (common on
100
+ # Windows) and is identical to utf-8 otherwise.
101
+ data = tomllib.loads(raw.decode("utf-8-sig"))
102
+ except tomllib.TOMLDecodeError as error:
103
+ raise ConfigError(f"Invalid config file '{path}': {error}")
104
+ except UnicodeDecodeError:
105
+ raise ConfigError(
106
+ f"Invalid config file '{path}': not valid UTF-8 "
107
+ f"(TOML files must be UTF-8 encoded; on Windows, beware that "
108
+ f"PowerShell redirection may create UTF-16 files)"
109
+ )
110
+
111
+ return _validate(data, path)
112
+
113
+
114
+ _AUTH_SCHEMES = {
115
+ "bearer": BearerAuth,
116
+ "api_key": ApiKeyAuth,
117
+ "basic": BasicAuth,
118
+ }
119
+
120
+
121
+ def _validate(data, path):
122
+ """Check keys and value types, returning a populated Config."""
123
+ top_level = {key: value for key, value in data.items() if key != "auth"}
124
+
125
+ unknown = set(top_level) - set(_KNOWN_KEYS)
126
+ if unknown:
127
+ names = ", ".join(sorted(unknown))
128
+ raise ConfigError(f"Unknown key(s) in config file '{path}': {names}")
129
+
130
+ for key, expected_type in _KNOWN_KEYS.items():
131
+ if key not in top_level:
132
+ continue
133
+ value = top_level[key]
134
+ # bool is a subclass of int in Python; reject it for timeout.
135
+ if isinstance(value, bool) or not isinstance(value, expected_type):
136
+ raise ConfigError(
137
+ f"Invalid value for '{key}' in config file '{path}': "
138
+ f"expected {expected_type.__name__}, got {type(value).__name__}"
139
+ )
140
+
141
+ auth = _validate_auth(data.get("auth", {}), path)
142
+ return Config(auth=auth, **top_level)
143
+
144
+
145
+ # Matches ${VAR} where VAR is a valid environment-variable name. The
146
+ # ${VAR|default} and bare $VAR forms are deliberately not supported.
147
+ _ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
148
+
149
+
150
+ def _substitute_env_vars(value, scheme, key, path):
151
+ """Replace every ${VAR} in a string with the environment value.
152
+
153
+ Raises ConfigError if a referenced variable is not set.
154
+ """
155
+ def replace(match):
156
+ var_name = match.group(1)
157
+ if var_name not in os.environ:
158
+ raise ConfigError(
159
+ f"Environment variable '{var_name}' referenced in "
160
+ f"[auth.{scheme}] ('{key}') of config file '{path}' is not set."
161
+ )
162
+ return os.environ[var_name]
163
+
164
+ return _ENV_VAR_PATTERN.sub(replace, value)
165
+
166
+
167
+ def _validate_auth(auth_data, path):
168
+ """Build and validate an AuthConfig from the [auth] section."""
169
+ unknown = set(auth_data) - set(_AUTH_SCHEMES)
170
+ if unknown:
171
+ names = ", ".join(sorted(unknown))
172
+ raise ConfigError(
173
+ f"Unknown auth scheme(s) in config file '{path}': {names}"
174
+ )
175
+
176
+ kwargs = {}
177
+ for scheme, scheme_class in _AUTH_SCHEMES.items():
178
+ if scheme not in auth_data:
179
+ continue
180
+ kwargs[scheme] = _build_auth_scheme(
181
+ scheme, scheme_class, auth_data[scheme], path
182
+ )
183
+
184
+ return AuthConfig(**kwargs)
185
+
186
+
187
+ def _build_auth_scheme(scheme, scheme_class, section, path):
188
+ """Validate one auth sub-table and build its dataclass."""
189
+ valid_fields = {f.name for f in fields(scheme_class)}
190
+ unknown = set(section) - valid_fields
191
+ if unknown:
192
+ names = ", ".join(sorted(unknown))
193
+ raise ConfigError(
194
+ f"Unknown key(s) in [auth.{scheme}] in config file '{path}': {names}"
195
+ )
196
+
197
+ resolved = {}
198
+ for key, value in section.items():
199
+ if not isinstance(value, str):
200
+ raise ConfigError(
201
+ f"Invalid value for '{key}' in [auth.{scheme}] in config file "
202
+ f"'{path}': expected str, got {type(value).__name__}"
203
+ )
204
+ resolved[key] = _substitute_env_vars(value, scheme, key, path)
205
+
206
+ return scheme_class(**resolved)
@@ -30,6 +30,7 @@ class Endpoint:
30
30
  request_body: dict | None = None
31
31
  responses: list[str] = field(default_factory=list)
32
32
  response_schema: dict | None = None
33
+ security: list[str] = field(default_factory=list)
33
34
 
34
35
 
35
36
  @dataclass
@@ -0,0 +1,91 @@
1
+ """Writing generated test files to disk.
2
+
3
+ This module groups scenarios by resource tag, renders each group into a
4
+ pytest file, and writes the files into the output directory. It only ever
5
+ writes into the output directory and never modifies the input
6
+ specification (WNF08).
7
+ """
8
+
9
+ import re
10
+ from pathlib import Path
11
+ from typing import NamedTuple
12
+
13
+ from swaggerforge.auth import resolve_auth
14
+ from swaggerforge.config import AuthConfig
15
+ from swaggerforge.template import render_test_file
16
+
17
+
18
+ class WriteResult(NamedTuple):
19
+ """The result of writing test files: the paths written and any warnings."""
20
+
21
+ written: list
22
+ warnings: list
23
+
24
+
25
+ def write_test_files(
26
+ scenarios,
27
+ base_url,
28
+ output_dir,
29
+ timeout=None,
30
+ security_schemes=None,
31
+ auth_config=None,
32
+ ):
33
+ """Group scenarios by tag, render them, and write one file per tag.
34
+
35
+ Authentication is resolved per file from the tag group's first
36
+ endpoint (file-level auth); a scheme that cannot be applied is
37
+ reported as a warning rather than stopping generation.
38
+
39
+ Args:
40
+ scenarios: A list of TestScenario objects.
41
+ base_url: The base URL of the API under test.
42
+ output_dir: Directory where the test files will be written.
43
+ timeout: Optional request timeout embedded in generated calls.
44
+ security_schemes: The specification's securitySchemes definitions.
45
+ auth_config: The AuthConfig carrying user-supplied credentials.
46
+
47
+ Returns:
48
+ A WriteResult with the sorted list of written paths and a list of
49
+ de-duplicated warnings raised while resolving authentication.
50
+ """
51
+ if security_schemes is None:
52
+ security_schemes = {}
53
+ if auth_config is None:
54
+ auth_config = AuthConfig()
55
+
56
+ grouped = _group_by_tag(scenarios)
57
+ output_path = Path(output_dir)
58
+ output_path.mkdir(parents=True, exist_ok=True)
59
+
60
+ written = []
61
+ warnings = []
62
+ for tag in sorted(grouped):
63
+ group = grouped[tag]
64
+ representative = group[0].endpoint
65
+ resolved = resolve_auth(representative, security_schemes, auth_config)
66
+ if resolved.warning and resolved.warning not in warnings:
67
+ warnings.append(resolved.warning)
68
+
69
+ filename = f"test_{_sanitize(tag)}.py"
70
+ file_path = output_path / filename
71
+ content = render_test_file(
72
+ filename, base_url, group, timeout, resolved.headers
73
+ )
74
+ file_path.write_text(content, encoding="utf-8")
75
+ written.append(str(file_path))
76
+
77
+ return WriteResult(written=written, warnings=warnings)
78
+
79
+
80
+ def _group_by_tag(scenarios):
81
+ """Group scenarios into a dict keyed by their endpoint's tag."""
82
+ grouped = {}
83
+ for scenario in scenarios:
84
+ tag = scenario.endpoint.tag
85
+ grouped.setdefault(tag, []).append(scenario)
86
+ return grouped
87
+
88
+
89
+ def _sanitize(tag):
90
+ """Replace characters not valid in a filename with underscores."""
91
+ return re.sub(r"[^a-zA-Z0-9_]", "_", tag)