Skip to content

Commit b7d34a7

Browse files
committed
misc improvements, test error conditions, remove redundant unit tests
1 parent 6b15783 commit b7d34a7

15 files changed

+301
-283
lines changed

Diff for: end_to_end_tests/end_to_end_test_helpers.py

+71-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import importlib
22
import os
3+
import re
34
import shutil
45
from filecmp import cmpfiles, dircmp
56
from pathlib import Path
@@ -92,7 +93,6 @@ def generate_client(
9293
if overwrite:
9394
args = [*args, "--overwrite"]
9495
generator_result = _run_command("generate", args, openapi_document, raise_on_error=raise_on_error)
95-
print(generator_result.stdout)
9696
return GeneratedClientContext(
9797
full_output_path,
9898
generator_result,
@@ -107,21 +107,20 @@ def generate_client_from_inline_spec(
107107
filename_suffix: Optional[str] = None,
108108
config: str = "",
109109
base_module: str = "testapi_client",
110-
add_openapi_info = True,
110+
add_missing_sections = True,
111111
raise_on_error: bool = True,
112112
) -> GeneratedClientContext:
113113
"""Run the generator on a temporary file created with the specified contents.
114114
115115
You can also optionally tell it to create a temporary config file.
116116
"""
117-
if add_openapi_info and not openapi_spec.lstrip().startswith("openapi:"):
118-
openapi_spec += """
119-
openapi: "3.1.0"
120-
info:
121-
title: "testapi"
122-
description: "my test api"
123-
version: "0.0.1"
124-
"""
117+
if add_missing_sections:
118+
if not re.search("^openapi:", openapi_spec, re.MULTILINE):
119+
openapi_spec += "\nopenapi: '3.1.0'\n"
120+
if not re.search("^info:", openapi_spec, re.MULTILINE):
121+
openapi_spec += "\ninfo: {'title': 'testapi', 'description': 'my test api', 'version': '0.0.1'}\n"
122+
if not re.search("^paths:", openapi_spec, re.MULTILINE):
123+
openapi_spec += "\npaths: {}\n"
125124

126125
output_path = tempfile.mkdtemp()
127126
file = tempfile.NamedTemporaryFile(suffix=filename_suffix, delete=False)
@@ -148,6 +147,43 @@ def generate_client_from_inline_spec(
148147
return generated_client
149148

150149

150+
def inline_spec_should_fail(
151+
openapi_spec: str,
152+
extra_args: List[str] = [],
153+
filename_suffix: Optional[str] = None,
154+
config: str = "",
155+
add_missing_sections = True,
156+
) -> Result:
157+
"""Asserts that the generator could not process the spec.
158+
159+
Returns the full output.
160+
"""
161+
with generate_client_from_inline_spec(
162+
openapi_spec, extra_args, filename_suffix, config, add_missing_sections, raise_on_error=False
163+
) as generated_client:
164+
assert generated_client.generator_result.exit_code != 0
165+
return generated_client.generator_result.stdout
166+
167+
168+
def inline_spec_should_cause_warnings(
169+
openapi_spec: str,
170+
extra_args: List[str] = [],
171+
filename_suffix: Optional[str] = None,
172+
config: str = "",
173+
add_openapi_info = True,
174+
) -> str:
175+
"""Asserts that the generator is able to process the spec, but printed warnings.
176+
177+
Returns the full output.
178+
"""
179+
with generate_client_from_inline_spec(
180+
openapi_spec, extra_args, filename_suffix, config, add_openapi_info, raise_on_error=True
181+
) as generated_client:
182+
assert generated_client.generator_result.exit_code == 0
183+
assert "Warning(s) encountered while generating" in generated_client.generator_result.stdout
184+
return generated_client.generator_result.stdout
185+
186+
151187
def with_generated_client_fixture(
152188
openapi_spec: str,
153189
name: str="generated_client",
@@ -197,7 +233,31 @@ def _func(self, generated_client):
197233
return _decorator
198234

199235

200-
def assert_model_decode_encode(model_class: Any, json_data: dict, expected_instance: Any):
236+
def with_generated_code_imports(*import_paths: str):
237+
def _decorator(cls):
238+
decorated = cls
239+
for import_path in import_paths:
240+
decorated = with_generated_code_import(import_path)(decorated)
241+
return decorated
242+
243+
return _decorator
244+
245+
246+
def assert_model_decode_encode(model_class: Any, json_data: dict, expected_instance: Any) -> None:
201247
instance = model_class.from_dict(json_data)
202248
assert instance == expected_instance
203249
assert instance.to_dict() == json_data
250+
251+
252+
def assert_bad_schema_warning(output: str, schema_name: str, expected_message_str) -> None:
253+
bad_schema_regex = "Unable to (parse|process) schema"
254+
expected_start_regex = f"{bad_schema_regex} /components/schemas/{re.escape(schema_name)}:?\n"
255+
if not (match := re.search(expected_start_regex, output)):
256+
# this assert is to get better failure output
257+
assert False, f"Did not find '{expected_start_regex}' in output: {output}"
258+
output = output[match.end():]
259+
# The amount of other information in between that message and the warning detail can vary
260+
# depending on the error, so just make sure we're not picking up output from a different schema
261+
if (next_match := re.search(bad_schema_regex, output)):
262+
output = output[0:next_match.start()]
263+
assert expected_message_str in output

Diff for: end_to_end_tests/generated_code_live_tests/README.md

+4-5
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ These are end-to-end tests which run the code generator command, but unlike the
55
Each test class follows this pattern:
66

77
- Use the decorator `@with_generated_client_fixture`, providing an inline API spec (JSON or YAML) that contains whatever schemas/paths/etc. are relevant to this test class.
8-
- The spec can omit the `openapi:` and `info:` blocks, unless those are relevant to the test.
8+
- The spec can omit the `openapi:`, `info:`, and `paths:`, blocks, unless those are relevant to the test.
99
- The decorator creates a temporary file for the inline spec and a temporary directory for the generated code, and runs the client generator.
1010
- It creates a `GeneratedClientContext` object (defined in `end_to_end_test_helpers.py`) to keep track of things like the location of the generated code and the output of the generator command.
1111
- This object is injected into the test class as a fixture called `generated_client`, although most tests will not need to reference the fixture directly.
1212
- `sys.path` is temporarily changed, for the scope of this test class, to allow imports from the generated code.
13-
- Use the decorator `@with_generated_code_import` to make classes or functions from the generated code available to the tests.
14-
- `@with_generated_code_import(".models.MyModel")` would execute `from [client package name].models import MyModel` and inject the imported object into the test class as a fixture called `MyModel`.
15-
- `@with_generated_code_import(".models.MyModel", alias="model1")` would do the same thing, but the fixture would be named `model1`.
13+
- Use the decorator `@with_generated_code_imports` or `@with_generated_code_import` to make classes or functions from the generated code available to the tests.
14+
- `@with_generated_code_imports(".models.MyModel1", ".models.MyModel2)` would execute `from [package name].models import MyModel1, MyModel2` and inject the imported classes into the test class as fixtures called `MyModel1` and `MyModel2`.
15+
- `@with_generated_code_import(".api.my_operation.sync", alias="endpoint_method")` would execute `from [package name].api.my_operation import sync`, but the fixture would be named `endpoint_method`.
1616
- After the test class finishes, these imports are discarded.
1717

1818
Example:
@@ -21,7 +21,6 @@ Example:
2121

2222
@with_generated_client_fixture(
2323
"""
24-
paths: {}
2524
components:
2625
schemas:
2726
MyModel:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
2+
import datetime
3+
import uuid
4+
import pytest
5+
from end_to_end_tests.end_to_end_test_helpers import (
6+
assert_bad_schema_warning,
7+
assert_model_decode_encode,
8+
inline_spec_should_cause_warnings,
9+
with_generated_client_fixture,
10+
with_generated_code_imports,
11+
)
12+
13+
14+
@with_generated_client_fixture(
15+
"""
16+
components:
17+
schemas:
18+
MyModel:
19+
type: object
20+
properties:
21+
booleanProp: {"type": "boolean", "default": true}
22+
stringProp: {"type": "string", "default": "a"}
23+
numberProp: {"type": "number", "default": 1.5}
24+
intProp: {"type": "integer", "default": 2}
25+
noneProp: {"type": "null", "default": null}
26+
anyPropWithString: {"default": "b"}
27+
anyPropWithInt: {"default": 3}
28+
booleanWithStringTrue1: {"type": "boolean", "default": "True"}
29+
booleanWithStringTrue2: {"type": "boolean", "default": "true"}
30+
booleanWithStringFalse1: {"type": "boolean", "default": "False"}
31+
booleanWithStringFalse2: {"type": "boolean", "default": "false"}
32+
intWithStringValue: {"type": "integer", "default": "4"}
33+
numberWithIntValue: {"type": "number", "default": 5}
34+
numberWithStringValue: {"type": "number", "default": "5.5"}
35+
noneWithStringValue: {"type": "null", "default": "None"}
36+
""")
37+
@with_generated_code_imports(".models.MyModel")
38+
class TestDefaultValues:
39+
def test_defaults_in_initializer(self, MyModel, generated_client):
40+
instance = MyModel()
41+
assert instance == MyModel(
42+
boolean_prop=True,
43+
string_prop="a",
44+
number_prop=1.5,
45+
int_prop=2,
46+
any_prop_with_string="b",
47+
any_prop_with_int=3,
48+
boolean_with_string_true_1=True,
49+
boolean_with_string_true_2=True,
50+
boolean_with_string_false_1=False,
51+
boolean_with_string_false_2=False,
52+
int_with_string_value=4,
53+
number_with_int_value=5,
54+
number_with_string_value=5.5,
55+
)
56+
# Note, currently the default for a None property does not work as expected--
57+
# the initializer will default it to UNSET rather than None.
58+
59+
60+
class TestInvalidDefaultValues:
61+
@pytest.fixture(scope="class")
62+
def warnings(self):
63+
return inline_spec_should_cause_warnings(
64+
"""
65+
components:
66+
schemas:
67+
WithBadBoolean:
68+
properties:
69+
badBoolean: {"type": "boolean", "default": "not a boolean"}
70+
WithBadIntAsString:
71+
properties:
72+
badInt: {"type": "integer", "default": "not an int"}
73+
WithBadIntAsOther:
74+
properties:
75+
badInt: {"type": "integer", "default": true}
76+
WithBadFloatAsString:
77+
properties:
78+
badInt: {"type": "number", "default": "not a number"}
79+
WithBadFloatAsOther:
80+
properties:
81+
badInt: {"type": "number", "default": true}
82+
"""
83+
)
84+
85+
def test_bad_boolean(self, warnings):
86+
assert_bad_schema_warning(warnings, "WithBadBoolean", "Invalid boolean value")
87+
88+
def test_bad_int_as_string(self, warnings):
89+
assert_bad_schema_warning(warnings, "WithBadIntAsString", "Invalid int value")
90+
91+
def test_bad_int_as_other(self, warnings):
92+
assert_bad_schema_warning(warnings, "WithBadIntAsOther", "Invalid int value")
93+
94+
def test_bad_float_as_string(self, warnings):
95+
assert_bad_schema_warning(warnings, "WithBadFloatAsString", "Invalid float value")
96+
97+
def test_bad_float_as_other(self, warnings):
98+
assert_bad_schema_warning(warnings, "WithBadFloatAsOther", "Cannot convert True to a float")

Diff for: end_to_end_tests/generated_code_live_tests/test_docstrings.py

-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ def get_section(self, header_line: str) -> List[str]:
1818

1919
@with_generated_client_fixture(
2020
"""
21-
paths: {}
2221
components:
2322
schemas:
2423
MyModel:

Diff for: end_to_end_tests/generated_code_live_tests/test_enums.py renamed to end_to_end_tests/generated_code_live_tests/test_enum_and_const.py

+59-8
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11

2+
from typing import Any
23
import pytest
34
from end_to_end_tests.end_to_end_test_helpers import (
5+
assert_bad_schema_warning,
46
assert_model_decode_encode,
7+
inline_spec_should_cause_warnings,
58
with_generated_code_import,
69
with_generated_client_fixture,
10+
with_generated_code_imports,
711
)
812

913

1014
@with_generated_client_fixture(
1115
"""
12-
paths: {}
1316
components:
1417
schemas:
1518
MyEnum:
@@ -27,9 +30,7 @@
2730
- {"$ref": "#/components/schemas/MyEnum"}
2831
- type: "null"
2932
""")
30-
@with_generated_code_import(".models.MyEnum")
31-
@with_generated_code_import(".models.MyIntEnum")
32-
@with_generated_code_import(".models.MyModel")
33+
@with_generated_code_imports(".models.MyEnum", ".models.MyIntEnum", ".models.MyModel")
3334
class TestEnumClasses:
3435
def test_enum_classes(self, MyEnum, MyIntEnum):
3536
assert MyEnum.A == MyEnum("a")
@@ -72,7 +73,6 @@ def test_invalid_values(self, MyModel):
7273

7374
@with_generated_client_fixture(
7475
"""
75-
paths: {}
7676
components:
7777
schemas:
7878
MyEnum:
@@ -130,24 +130,75 @@ def test_invalid_values(self, MyModel):
130130

131131
@with_generated_client_fixture(
132132
"""
133-
paths: {}
134133
components:
135134
schemas:
136135
MyModel:
137136
properties:
138137
mustBeErnest:
139138
const: Ernest
139+
mustBeThirty:
140+
const: 30
140141
""",
141142
)
142143
@with_generated_code_import(".models.MyModel")
143144
class TestConst:
144-
def test_valid_value(self, MyModel):
145+
def test_valid_string(self, MyModel):
145146
assert_model_decode_encode(
146147
MyModel,
147148
{"mustBeErnest": "Ernest"},
148149
MyModel(must_be_ernest="Ernest"),
149150
)
150151

151-
def test_invalid_value(self, MyModel):
152+
def test_valid_int(self, MyModel):
153+
assert_model_decode_encode(
154+
MyModel,
155+
{"mustBeThirty": 30},
156+
MyModel(must_be_thirty=30),
157+
)
158+
159+
def test_invalid_string(self, MyModel):
152160
with pytest.raises(ValueError):
153161
MyModel.from_dict({"mustBeErnest": "Jack"})
162+
163+
def test_invalid_int(self, MyModel):
164+
with pytest.raises(ValueError):
165+
MyModel.from_dict({"mustBeThirty": 29})
166+
167+
168+
class TestEnumAndConstInvalidSchemas:
169+
@pytest.fixture(scope="class")
170+
def warnings(self):
171+
return inline_spec_should_cause_warnings(
172+
"""
173+
components:
174+
schemas:
175+
WithBadDefaultValue:
176+
enum: ["A"]
177+
default: "B"
178+
WithBadDefaultType:
179+
enum: ["A"]
180+
default: 123
181+
WithMixedTypes:
182+
enum: ["A", 1]
183+
WithUnsupportedType:
184+
enum: [1.4, 1.5]
185+
DefaultNotMatchingConst:
186+
const: "aaa"
187+
default: "bbb"
188+
"""
189+
)
190+
191+
def test_enum_bad_default_value(self, warnings):
192+
assert_bad_schema_warning(warnings, "WithBadDefaultValue", "Value B is not valid")
193+
194+
def test_enum_bad_default_type(self, warnings):
195+
assert_bad_schema_warning(warnings, "WithBadDefaultType", "Cannot convert 123 to enum")
196+
197+
def test_enum_mixed_types(self, warnings):
198+
assert_bad_schema_warning(warnings, "WithMixedTypes", "Enum values must all be the same type")
199+
200+
def test_enum_unsupported_type(self, warnings):
201+
assert_bad_schema_warning(warnings, "WithUnsupportedType", "Unsupported enum type")
202+
203+
def test_const_default_not_matching(self, warnings):
204+
assert_bad_schema_warning(warnings, "DefaultNotMatchingConst", "Invalid value for const")

0 commit comments

Comments
 (0)