Skip to content

Commit 3909b00

Browse files
committed
Graceful error handling
- Closes #51 - Bundles errors together to display them all at the end - Generates incomplete clients on error
1 parent 6f87a3c commit 3909b00

File tree

10 files changed

+155
-36
lines changed

10 files changed

+155
-36
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
- Support for Union types ("anyOf" in OpenAPI document)
2121
- Support for more basic response types (integer, number, boolean)
2222
- Better error messages when failing to parse an OpenAPI document
23+
- Error messages will contain some useful information about why it failed instead of just a stack trace
24+
- Client will still be generated if there are errors, excluding endpoints that had errors
2325

2426
### Changes
2527
- The way most imports are handled was changed which *should* lead to fewer unused imports in generated files.

openapi_python_client/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from openapi_python_client import utils
1616

1717
from .openapi_parser import OpenAPI, import_string_from_reference
18+
from .openapi_parser.errors import MultipleParseError
1819

1920
__version__ = version(__package__)
2021

@@ -88,6 +89,7 @@ def build(self) -> None:
8889
self._build_models()
8990
self._build_api()
9091
self._reformat()
92+
self._raise_errors()
9193

9294
def update(self) -> None:
9395
""" Update an existing project """
@@ -100,11 +102,19 @@ def update(self) -> None:
100102
self._build_models()
101103
self._build_api()
102104
self._reformat()
105+
self._raise_errors()
103106

104107
def _reformat(self) -> None:
105108
subprocess.run("isort --recursive --apply", cwd=self.project_dir, shell=True)
106109
subprocess.run("black .", cwd=self.project_dir, shell=True)
107110

111+
def _raise_errors(self) -> None:
112+
errors = []
113+
for collection in self.openapi.endpoint_collections_by_tag.values():
114+
errors.extend(collection.parse_errors)
115+
if errors:
116+
raise MultipleParseError(parse_errors=errors)
117+
108118
def _create_package(self) -> None:
109119
self.package_dir.mkdir()
110120
# Package __init__.py

openapi_python_client/cli.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import typer
77

8-
from openapi_python_client.openapi_parser.errors import ParseError
8+
from openapi_python_client.openapi_parser.errors import MultipleParseError, ParseError
99

1010
app = typer.Typer()
1111

@@ -42,22 +42,31 @@ def cli(
4242
pass
4343

4444

45+
def _print_parser_error(e: ParseError) -> None:
46+
formatted_data = pformat(e.data)
47+
typer.secho(e.header, bold=True, fg=typer.colors.BRIGHT_RED, err=True)
48+
typer.secho(formatted_data, fg=typer.colors.RED, err=True)
49+
if e.message:
50+
typer.secho(e.message, fg=typer.colors.BRIGHT_RED, err=True)
51+
gh_link = typer.style(
52+
"https://github.com/triaxtec/openapi-python-client/issues/new/choose", fg=typer.colors.BRIGHT_BLUE
53+
)
54+
typer.secho(f"Please open an issue at {gh_link}", fg=typer.colors.RED, err=True)
55+
typer.secho()
56+
57+
4558
@contextmanager
4659
def handle_errors() -> Generator[None, None, None]:
4760
""" Turn custom errors into formatted error messages """
4861
try:
4962
yield
5063
except ParseError as e:
51-
formatted_data = pformat(e.data)
52-
typer.secho("ERROR: Unable to parse this part of your OpenAPI document: ", fg=typer.colors.BRIGHT_RED, err=True)
53-
typer.secho(formatted_data, fg=typer.colors.RED, err=True)
54-
if e.message:
55-
typer.secho(e.message, fg=typer.colors.BRIGHT_RED, err=True)
56-
gh_link = typer.style(
57-
"https://github.com/triaxtec/openapi-python-client/issues/new/choose", fg=typer.colors.BRIGHT_BLUE
58-
)
59-
typer.secho(f"Please open an issue at {gh_link}", fg=typer.colors.RED, err=True)
60-
typer.secho()
64+
_print_parser_error(e)
65+
raise typer.Exit(code=1)
66+
except MultipleParseError as e:
67+
typer.secho("MULTIPLE ERRORS WHILE PARSING:", underline=True, bold=True, fg=typer.colors.BRIGHT_RED, err=True)
68+
for err in e.parse_errors:
69+
_print_parser_error(err)
6170
raise typer.Exit(code=1)
6271

6372

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
1-
from typing import Any, Optional
1+
from typing import Any, List, Optional
22

33

44
class ParseError(ValueError):
55
""" An error raised when there's a problem parsing an OpenAPI document """
66

7-
def __init__(self, data: Any, message: Optional[str] = None):
7+
def __init__(
8+
self,
9+
data: Any,
10+
header: str = "ERROR: Unable to parse this part of your OpenAPI document: ",
11+
message: Optional[str] = None,
12+
):
813
super().__init__()
914
self.data = data
1015
self.message = message
16+
self.header = header
17+
18+
19+
class MultipleParseError(ValueError):
20+
""" Higher level error combining multiple ParseErrors """
21+
22+
def __init__(self, parse_errors: List[ParseError]):
23+
super().__init__()
24+
self.parse_errors = parse_errors

openapi_python_client/openapi_parser/openapi.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from enum import Enum
55
from typing import Any, Dict, Generator, Iterable, List, Optional, Set
66

7+
from .errors import ParseError
78
from .properties import EnumProperty, ListProperty, Property, property_from_dict
89
from .reference import Reference
910
from .responses import ListRefResponse, RefResponse, Response, response_from_dict
@@ -28,17 +29,25 @@ class EndpointCollection:
2829
tag: str
2930
endpoints: List[Endpoint] = field(default_factory=list)
3031
relative_imports: Set[str] = field(default_factory=set)
32+
parse_errors: List[ParseError] = field(default_factory=list)
3133

3234
@staticmethod
3335
def from_dict(d: Dict[str, Dict[str, Dict[str, Any]]], /) -> Dict[str, EndpointCollection]:
3436
""" Parse the openapi paths data to get EndpointCollections by tag """
3537
endpoints_by_tag: Dict[str, EndpointCollection] = {}
38+
3639
for path, path_data in d.items():
3740
for method, method_data in path_data.items():
38-
endpoint = Endpoint.from_data(data=method_data, path=path, method=method)
39-
collection = endpoints_by_tag.setdefault(endpoint.tag, EndpointCollection(tag=endpoint.tag))
40-
collection.endpoints.append(endpoint)
41-
collection.relative_imports.update(endpoint.relative_imports)
41+
tag = method_data.get("tags", ["default"])[0]
42+
collection = endpoints_by_tag.setdefault(tag, EndpointCollection(tag=tag))
43+
try:
44+
endpoint = Endpoint.from_data(data=method_data, path=path, method=method, tag=tag)
45+
collection.endpoints.append(endpoint)
46+
collection.relative_imports.update(endpoint.relative_imports)
47+
except ParseError as e:
48+
e.header = f"ERROR parsing {method.upper()} {path} within {tag}. Endpoint will not be generated."
49+
collection.parse_errors.append(e)
50+
4251
return endpoints_by_tag
4352

4453

@@ -127,7 +136,7 @@ def _add_parameters(self, data: Dict[str, Any]) -> None:
127136
raise ValueError(f"Don't know where to put this parameter: {param_dict}")
128137

129138
@staticmethod
130-
def from_data(*, data: Dict[str, Any], path: str, method: str) -> Endpoint:
139+
def from_data(*, data: Dict[str, Any], path: str, method: str, tag: str) -> Endpoint:
131140
""" Construct an endpoint from the OpenAPI data """
132141

133142
endpoint = Endpoint(
@@ -136,7 +145,7 @@ def from_data(*, data: Dict[str, Any], path: str, method: str) -> Endpoint:
136145
description=data.get("description"),
137146
name=data["operationId"],
138147
requires_security=bool(data.get("security")),
139-
tag=data.get("tags", ["default"])[0],
148+
tag=tag,
140149
)
141150
endpoint._add_parameters(data)
142151
endpoint._add_responses(data)

openapi_python_client/openapi_parser/properties.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ def _string_based_property(
335335
elif string_format == "binary":
336336
return FileProperty(name=name, required=required, default=data.get("default"))
337337
else:
338-
raise ValueError(f'Unsupported string format:{data["format"]}')
338+
raise ParseError(data=data, message=f'Unsupported string format:{data["format"]}')
339339

340340

341341
def property_from_dict(name: str, required: bool, data: Dict[str, Any]) -> Property:

openapi_python_client/openapi_parser/responses.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,4 @@ def response_from_dict(*, status_code: int, data: _ResponseDict) -> Response:
109109
return ListRefResponse(status_code=status_code, reference=Reference.from_ref(schema_data["items"]["$ref"]),)
110110
if response_type in openapi_types_to_python_type_strings:
111111
return BasicResponse(status_code=status_code, openapi_type=response_type)
112-
raise ParseError(data, f"Unrecognized type {schema_data['type']}")
112+
raise ParseError(data, message=f"Unrecognized type {schema_data['type']}")

tests/test___init__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ def test_build(self, mocker):
136136
project._build_api = mocker.MagicMock()
137137
project._create_package = mocker.MagicMock()
138138
project._reformat = mocker.MagicMock()
139+
project._raise_errors = mocker.MagicMock()
139140

140141
project.build()
141142

@@ -145,6 +146,7 @@ def test_build(self, mocker):
145146
project._build_models.assert_called_once()
146147
project._build_api.assert_called_once()
147148
project._reformat.assert_called_once()
149+
project._raise_errors.assert_called_once()
148150

149151
def test_update(self, mocker):
150152
from openapi_python_client import _Project, shutil
@@ -157,6 +159,7 @@ def test_update(self, mocker):
157159
project._build_api = mocker.MagicMock()
158160
project._create_package = mocker.MagicMock()
159161
project._reformat = mocker.MagicMock()
162+
project._raise_errors = mocker.MagicMock()
160163

161164
project.update()
162165

@@ -165,6 +168,7 @@ def test_update(self, mocker):
165168
project._build_models.assert_called_once()
166169
project._build_api.assert_called_once()
167170
project._reformat.assert_called_once()
171+
project._raise_errors.assert_called_once()
168172

169173
def test_update_missing_dir(self, mocker):
170174
from openapi_python_client import _Project
@@ -448,6 +452,25 @@ def test__reformat(mocker):
448452
)
449453

450454

455+
def test__raise_errors(mocker):
456+
from openapi_python_client import _Project, OpenAPI, MultipleParseError
457+
from openapi_python_client.openapi_parser.openapi import EndpointCollection
458+
459+
openapi = mocker.MagicMock(
460+
autospec=OpenAPI,
461+
title="My Test API",
462+
endpoint_collections_by_tag={
463+
"default": mocker.MagicMock(autospec=EndpointCollection, parse_errors=[1]),
464+
"other": mocker.MagicMock(autospec=EndpointCollection, parse_errors=[2]),
465+
},
466+
)
467+
project = _Project(openapi=openapi)
468+
469+
with pytest.raises(MultipleParseError) as exc_info:
470+
project._raise_errors()
471+
assert exc_info.value.parse_errors == [1, 2]
472+
473+
451474
def test_load_config(mocker):
452475
my_data = {"class_overrides": {"_MyCLASSName": {"class_name": "MyClassName", "module_name": "my_module_name"}}}
453476
safe_load = mocker.patch("yaml.safe_load", return_value=my_data)

tests/test_cli.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55
from typer.testing import CliRunner
66

7+
from openapi_python_client import MultipleParseError
78
from openapi_python_client.openapi_parser.errors import ParseError
89

910
runner = CliRunner()
@@ -90,7 +91,7 @@ def test_generate_path(self, _create_new_client):
9091
_create_new_client.assert_called_once_with(url=None, path=PosixPath(path))
9192

9293
def test_generate_handle_errors(self, _create_new_client):
93-
_create_new_client.side_effect = ParseError({"test": "data"}, "this is a message")
94+
_create_new_client.side_effect = ParseError({"test": "data"}, message="this is a message")
9495
path = "cool/path"
9596
from openapi_python_client.cli import app
9697

@@ -104,6 +105,29 @@ def test_generate_handle_errors(self, _create_new_client):
104105
"Please open an issue at https://github.com/triaxtec/openapi-python-client/issues/new/choose\n\n"
105106
)
106107

108+
def test_generate_handle_multiple_parse_error(self, _create_new_client):
109+
error_1 = ParseError({"test": "data"}, message="this is a message")
110+
error_2 = ParseError({"other": "data"}, message="this is another message", header="Custom Header")
111+
error = MultipleParseError([error_1, error_2])
112+
_create_new_client.side_effect = error
113+
path = "cool/path"
114+
from openapi_python_client.cli import app
115+
116+
result = runner.invoke(app, ["generate", f"--path={path}"])
117+
118+
assert result.exit_code == 1
119+
assert result.output == (
120+
"MULTIPLE ERRORS WHILE PARSING:\n"
121+
"ERROR: Unable to parse this part of your OpenAPI document: \n"
122+
"{'test': 'data'}\n"
123+
"this is a message\n"
124+
"Please open an issue at https://github.com/triaxtec/openapi-python-client/issues/new/choose\n\n"
125+
"Custom Header\n"
126+
"{'other': 'data'}\n"
127+
"this is another message\n"
128+
"Please open an issue at https://github.com/triaxtec/openapi-python-client/issues/new/choose\n\n"
129+
)
130+
107131

108132
@pytest.fixture
109133
def _update_existing_client(mocker):

tests/test_openapi_parser/test_openapi.py

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ def test_from_data(self, mocker):
456456
"security": {"blah": "bloo"},
457457
}
458458

459-
endpoint = Endpoint.from_data(data=data, path=path, method=method)
459+
endpoint = Endpoint.from_data(data=data, path=path, method=method, tag="default")
460460

461461
assert endpoint.path == path
462462
assert endpoint.method == method
@@ -468,10 +468,9 @@ def test_from_data(self, mocker):
468468
_add_responses.assert_called_once_with(data)
469469
_add_body.assert_called_once_with(data)
470470

471-
data["tags"] = ["a", "b"]
472471
del data["security"]
473472

474-
endpoint = Endpoint.from_data(data=data, path=path, method=method)
473+
endpoint = Endpoint.from_data(data=data, path=path, method=method, tag="a")
475474

476475
assert not endpoint.requires_security
477476
assert endpoint.tag == "a"
@@ -502,16 +501,16 @@ class TestEndpointCollection:
502501
def test_from_dict(self, mocker):
503502
from openapi_python_client.openapi_parser.openapi import EndpointCollection, Endpoint
504503

505-
data_1 = mocker.MagicMock()
506-
data_2 = mocker.MagicMock()
507-
data_3 = mocker.MagicMock()
504+
data_1 = {}
505+
data_2 = {"tags": ["tag_2", "tag_3"]}
506+
data_3 = {}
508507
data = {
509-
"path_1": {"method_1": data_1, "method_2": data_2,},
510-
"path_2": {"method_1": data_3,},
508+
"path_1": {"method_1": data_1, "method_2": data_2},
509+
"path_2": {"method_1": data_3},
511510
}
512-
endpoint_1 = mocker.MagicMock(autospec=Endpoint, tag="tag_1", relative_imports={"1", "2"})
511+
endpoint_1 = mocker.MagicMock(autospec=Endpoint, tag="default", relative_imports={"1", "2"})
513512
endpoint_2 = mocker.MagicMock(autospec=Endpoint, tag="tag_2", relative_imports={"2"})
514-
endpoint_3 = mocker.MagicMock(autospec=Endpoint, tag="tag_1", relative_imports={"2", "3"})
513+
endpoint_3 = mocker.MagicMock(autospec=Endpoint, tag="default", relative_imports={"2", "3"})
515514
endpoint_from_data = mocker.patch.object(
516515
Endpoint, "from_data", side_effect=[endpoint_1, endpoint_2, endpoint_3]
517516
)
@@ -520,12 +519,41 @@ def test_from_dict(self, mocker):
520519

521520
endpoint_from_data.assert_has_calls(
522521
[
523-
mocker.call(data=data_1, path="path_1", method="method_1"),
524-
mocker.call(data=data_2, path="path_1", method="method_2"),
525-
mocker.call(data=data_3, path="path_2", method="method_1"),
522+
mocker.call(data=data_1, path="path_1", method="method_1", tag="default"),
523+
mocker.call(data=data_2, path="path_1", method="method_2", tag="tag_2"),
524+
mocker.call(data=data_3, path="path_2", method="method_1", tag="default"),
526525
]
527526
)
528527
assert result == {
529-
"tag_1": EndpointCollection("tag_1", endpoints=[endpoint_1, endpoint_3], relative_imports={"1", "2", "3"}),
528+
"default": EndpointCollection(
529+
"default", endpoints=[endpoint_1, endpoint_3], relative_imports={"1", "2", "3"}
530+
),
530531
"tag_2": EndpointCollection("tag_2", endpoints=[endpoint_2], relative_imports={"2"}),
531532
}
533+
534+
def test_from_dict_errors(self, mocker):
535+
from openapi_python_client.openapi_parser.openapi import EndpointCollection, Endpoint, ParseError
536+
537+
data_1 = {}
538+
data_2 = {"tags": ["tag_2", "tag_3"]}
539+
data_3 = {}
540+
data = {
541+
"path_1": {"method_1": data_1, "method_2": data_2},
542+
"path_2": {"method_1": data_3},
543+
}
544+
endpoint_from_data = mocker.patch.object(
545+
Endpoint, "from_data", side_effect=[ParseError("1"), ParseError("2"), ParseError("3")]
546+
)
547+
548+
result = EndpointCollection.from_dict(data)
549+
550+
endpoint_from_data.assert_has_calls(
551+
[
552+
mocker.call(data=data_1, path="path_1", method="method_1", tag="default"),
553+
mocker.call(data=data_2, path="path_1", method="method_2", tag="tag_2"),
554+
mocker.call(data=data_3, path="path_2", method="method_1", tag="default"),
555+
]
556+
)
557+
assert result["default"].parse_errors[0].data == "1"
558+
assert result["default"].parse_errors[1].data == "3"
559+
assert result["tag_2"].parse_errors[0].data == "2"

0 commit comments

Comments
 (0)