Skip to content

Commit 3afa089

Browse files
committed
Make error handling more robust (#107).
- Create vendored version of openapi-pydantic-schema in `schema`. - Update to this projects quality standards. - Allow (ignore) extra keys when parsing schema - Redo error handling to be much more robust so more pieces of the generator can degrade gracefully instead of outright failing.
1 parent 504b49f commit 3afa089

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+3060
-368
lines changed

CHANGELOG.md

+11-2
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77

88
## 0.5.0 - Unreleased
9+
### Changes
10+
- When encountering a problem, the generator will now differentiate between warnings (things it was able to skip past)
11+
and errors (things which halt generation altogether).
12+
13+
### Additions
14+
- The generator can now handle many more errors gracefully, skipping the things it can't generate and continuing
15+
with the pieces it can.
16+
917
### Internal Changes
10-
- Switched OpenAPI document parsing to use
11-
[openapi-schema-pydantic](https://github.com/kuimono/openapi-schema-pydantic/pull/1) (#103)
18+
- Switched OpenAPI document parsing to use Pydantic based on a vendored version of
19+
[openapi-schema-pydantic](https://github.com/kuimono/openapi-schema-pydantic/) (#103).
20+
1221

1322

1423
## 0.4.2 - 2020-06-13

openapi_python_client/__init__.py

+32-19
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@
66
import subprocess
77
import sys
88
from pathlib import Path
9-
from typing import Any, Dict, Optional
9+
from typing import Any, Dict, Optional, Sequence
1010

1111
import httpx
1212
import yaml
1313
from jinja2 import Environment, PackageLoader
1414

1515
from openapi_python_client import utils
1616

17-
from .openapi_parser import GeneratorData, import_string_from_reference
18-
from .openapi_parser.errors import MultipleParseError
17+
from .parser import GeneratorData, import_string_from_reference
18+
from .parser.errors import GeneratorError
1919

2020
if sys.version_info.minor == 7: # version did not exist in 3.7, need to use a backport
2121
from importlib_metadata import version # type: ignore
@@ -37,22 +37,32 @@ def load_config(*, path: Path) -> None:
3737
config_data = yaml.safe_load(path.read_text())
3838

3939
if "class_overrides" in config_data:
40-
from .openapi_parser import reference
40+
from .parser import reference
4141

4242
for class_name, class_data in config_data["class_overrides"].items():
4343
reference.class_overrides[class_name] = reference.Reference(**class_data)
4444

4545

46-
def create_new_client(*, url: Optional[str], path: Optional[Path]) -> None:
47-
""" Generate the client library """
46+
def create_new_client(*, url: Optional[str], path: Optional[Path]) -> Sequence[GeneratorError]:
47+
"""
48+
Generate the client library
49+
50+
Returns:
51+
A list containing any errors encountered when generating.
52+
"""
4853
project = _get_project_for_url_or_path(url=url, path=path)
49-
project.build()
54+
return project.build()
55+
5056

57+
def update_existing_client(*, url: Optional[str], path: Optional[Path]) -> Sequence[GeneratorError]:
58+
"""
59+
Update an existing client library
5160
52-
def update_existing_client(*, url: Optional[str], path: Optional[Path]) -> None:
53-
""" Update an existing client library """
61+
Returns:
62+
A list containing any errors encountered when generating.
63+
"""
5464
project = _get_project_for_url_or_path(url=url, path=path)
55-
project.update()
65+
return project.update()
5666

5767

5868
def _get_json(*, url: Optional[str], path: Optional[Path]) -> Dict[str, Any]:
@@ -85,19 +95,22 @@ def __init__(self, *, openapi: GeneratorData) -> None:
8595

8696
self.env.filters.update(self.TEMPLATE_FILTERS)
8797

88-
def build(self) -> None:
98+
def build(self) -> Sequence[GeneratorError]:
8999
""" Create the project from templates """
90100

91101
print(f"Generating {self.project_name}")
92-
self.project_dir.mkdir()
102+
try:
103+
self.project_dir.mkdir()
104+
except FileExistsError:
105+
return [GeneratorError(detail="Directory already exists. Delete it or use the update command.")]
93106
self._create_package()
94107
self._build_metadata()
95108
self._build_models()
96109
self._build_api()
97110
self._reformat()
98-
self._raise_errors()
111+
return self._get_errors()
99112

100-
def update(self) -> None:
113+
def update(self) -> Sequence[GeneratorError]:
101114
""" Update an existing project """
102115

103116
if not self.package_dir.is_dir():
@@ -108,20 +121,20 @@ def update(self) -> None:
108121
self._build_models()
109122
self._build_api()
110123
self._reformat()
111-
self._raise_errors()
124+
return self._get_errors()
112125

113126
def _reformat(self) -> None:
114127
subprocess.run(
115128
"isort .", cwd=self.project_dir, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
116129
)
117130
subprocess.run("black .", cwd=self.project_dir, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
118131

119-
def _raise_errors(self) -> None:
132+
def _get_errors(self) -> Sequence[GeneratorError]:
120133
errors = []
121134
for collection in self.openapi.endpoint_collections_by_tag.values():
122135
errors.extend(collection.parse_errors)
123-
if errors:
124-
raise MultipleParseError(parse_errors=errors)
136+
errors.extend(self.openapi.schemas.errors)
137+
return errors
125138

126139
def _create_package(self) -> None:
127140
self.package_dir.mkdir()
@@ -170,7 +183,7 @@ def _build_models(self) -> None:
170183
types_path.write_text(types_template.render())
171184

172185
model_template = self.env.get_template("model.pyi")
173-
for model in self.openapi.models.values():
186+
for model in self.openapi.schemas.models.values():
174187
module_path = models_dir / f"{model.reference.module_name}.py"
175188
module_path.write_text(model_template.render(model=model))
176189
imports.append(import_string_from_reference(model.reference))

openapi_python_client/cli.py

+53-32
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import pathlib
2-
from contextlib import contextmanager
32
from pprint import pformat
4-
from typing import Generator, Optional
3+
from typing import Optional, Sequence
54

65
import typer
76

8-
from openapi_python_client.openapi_parser.errors import MultipleParseError, ParseError
7+
from openapi_python_client.parser.errors import ErrorLevel, GeneratorError, ParseError
98

109
app = typer.Typer()
1110

@@ -42,35 +41,56 @@ def cli(
4241
pass
4342

4443

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)
44+
def _print_parser_error(e: GeneratorError, color: str) -> None:
45+
typer.secho(e.header, bold=True, fg=color, err=True)
46+
if e.detail:
47+
typer.secho(e.detail, fg=color, err=True)
48+
49+
if isinstance(e, ParseError) and e.data is not None:
50+
formatted_data = pformat(e.data)
51+
typer.secho(formatted_data, fg=color, err=True)
52+
5553
typer.secho()
5654

5755

58-
@contextmanager
59-
def handle_errors() -> Generator[None, None, None]:
56+
def handle_errors(errors: Sequence[GeneratorError]) -> None:
6057
""" Turn custom errors into formatted error messages """
61-
try:
62-
yield
63-
except ParseError as e:
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)
70-
raise typer.Exit(code=1)
71-
except FileExistsError:
72-
typer.secho("Directory already exists. Delete it or use the update command.", fg=typer.colors.RED, err=True)
73-
raise typer.Exit(code=1)
58+
if len(errors) == 0:
59+
return
60+
color = typer.colors.YELLOW
61+
for error in errors:
62+
if error.level == ErrorLevel.ERROR:
63+
typer.secho(
64+
"Error(s) encountered while generating, client was not created",
65+
underline=True,
66+
bold=True,
67+
fg=typer.colors.BRIGHT_RED,
68+
err=True,
69+
)
70+
color = typer.colors.RED
71+
break
72+
else:
73+
typer.secho(
74+
"Warning(s) encountered while generating. Client was generated, but some pieces may be missing",
75+
underline=True,
76+
bold=True,
77+
fg=typer.colors.BRIGHT_YELLOW,
78+
err=True,
79+
)
80+
81+
for err in errors:
82+
_print_parser_error(err, color)
83+
84+
gh_link = typer.style(
85+
"https://github.com/triaxtec/openapi-python-client/issues/new/choose", fg=typer.colors.BRIGHT_BLUE
86+
)
87+
typer.secho(
88+
f"If you believe this was a mistake or this tool is missing a feature you need, "
89+
f"please open an issue at {gh_link}",
90+
fg=typer.colors.BLUE,
91+
err=True,
92+
)
93+
raise typer.Exit(code=1)
7494

7595

7696
@app.command()
@@ -87,8 +107,8 @@ def generate(
87107
if url and path:
88108
typer.secho("Provide either --url or --path, not both", fg=typer.colors.RED)
89109
raise typer.Exit(code=1)
90-
with handle_errors():
91-
create_new_client(url=url, path=path)
110+
errors = create_new_client(url=url, path=path)
111+
handle_errors(errors)
92112

93113

94114
@app.command()
@@ -105,5 +125,6 @@ def update(
105125
if url and path:
106126
typer.secho("Provide either --url or --path, not both", fg=typer.colors.RED)
107127
raise typer.Exit(code=1)
108-
with handle_errors():
109-
update_existing_client(url=url, path=path)
128+
129+
errors = update_existing_client(url=url, path=path)
130+
handle_errors(errors)

openapi_python_client/openapi_parser/errors.py

-24
This file was deleted.
+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from dataclasses import dataclass
2+
from enum import Enum
3+
from typing import Optional
4+
5+
__all__ = ["GeneratorError", "ParseError", "PropertyError"]
6+
7+
from pydantic import BaseModel
8+
9+
10+
class ErrorLevel(Enum):
11+
""" The level of an error """
12+
13+
WARNING = "WARNING" # Client is still generated but missing some pieces
14+
ERROR = "ERROR" # Client could not be generated
15+
16+
17+
@dataclass
18+
class GeneratorError:
19+
""" Base data struct containing info on an error that occurred """
20+
21+
detail: Optional[str] = None
22+
level: ErrorLevel = ErrorLevel.ERROR
23+
header: str = "Unable to generate the client"
24+
25+
26+
@dataclass
27+
class ParseError(GeneratorError):
28+
""" An error raised when there's a problem parsing an OpenAPI document """
29+
30+
level: ErrorLevel = ErrorLevel.WARNING
31+
data: Optional[BaseModel] = None
32+
header: str = "Unable to parse this part of your OpenAPI document: "
33+
34+
35+
@dataclass
36+
class PropertyError(ParseError):
37+
""" Error raised when there's a problem creating a Property """
38+
39+
header = "Problem creating a Property: "

0 commit comments

Comments
 (0)