Skip to content

Commit c17a461

Browse files
committed
fix: Don't crash the generator when one of the post-generation hooks is missing [fixes #479]. Thanks @chamini2 and @karolzlot!
1 parent 367487d commit c17a461

File tree

3 files changed

+93
-70
lines changed

3 files changed

+93
-70
lines changed

openapi_python_client/__init__.py

+35-26
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import sys
66
from enum import Enum
77
from pathlib import Path
8-
from typing import Any, Dict, Optional, Sequence, Union
8+
from subprocess import CalledProcessError
9+
from typing import Any, Dict, List, Optional, Sequence, Union
910

1011
import httpcore
1112
import httpx
@@ -16,7 +17,7 @@
1617

1718
from .config import Config
1819
from .parser import GeneratorData, import_string_from_class
19-
from .parser.errors import GeneratorError
20+
from .parser.errors import ErrorLevel, GeneratorError
2021

2122
if sys.version_info.minor < 8: # version did not exist before 3.8, need to use a backport
2223
from importlib_metadata import version
@@ -96,6 +97,7 @@ def __init__(
9697
project_name=self.project_name,
9798
project_dir=self.project_dir,
9899
)
100+
self.errors: List[GeneratorError] = []
99101

100102
def build(self) -> Sequence[GeneratorError]:
101103
"""Create the project from templates"""
@@ -112,7 +114,7 @@ def build(self) -> Sequence[GeneratorError]:
112114
self._build_metadata()
113115
self._build_models()
114116
self._build_api()
115-
self._reformat()
117+
self._run_post_hooks()
116118
return self._get_errors()
117119

118120
def update(self) -> Sequence[GeneratorError]:
@@ -125,35 +127,42 @@ def update(self) -> Sequence[GeneratorError]:
125127
self._create_package()
126128
self._build_models()
127129
self._build_api()
128-
self._reformat()
130+
self._run_post_hooks()
129131
return self._get_errors()
130132

131-
def _reformat(self) -> None:
132-
subprocess.run(
133-
"autoflake -i -r --remove-all-unused-imports --remove-unused-variables --ignore-init-module-imports .",
134-
cwd=self.package_dir,
135-
shell=True,
136-
stdout=subprocess.PIPE,
137-
stderr=subprocess.PIPE,
138-
check=True,
139-
)
140-
subprocess.run(
141-
"isort .",
142-
cwd=self.project_dir,
143-
shell=True,
144-
stdout=subprocess.PIPE,
145-
stderr=subprocess.PIPE,
146-
check=True,
147-
)
148-
subprocess.run(
149-
"black .", cwd=self.project_dir, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True
150-
)
133+
def _run_post_hooks(self) -> None:
134+
for command in self.config.post_hooks:
135+
self._run_command(command)
136+
137+
def _run_command(self, cmd: str) -> None:
138+
cmd_name = cmd.split(" ")[0]
139+
command_exists = shutil.which(cmd_name)
140+
if not command_exists:
141+
self.errors.append(
142+
GeneratorError(
143+
level=ErrorLevel.WARNING, header="Skipping Integration", detail=f"{cmd_name} is not in PATH"
144+
)
145+
)
146+
return
147+
try:
148+
subprocess.run(
149+
cmd, cwd=self.project_dir, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True
150+
)
151+
except CalledProcessError as err:
152+
self.errors.append(
153+
GeneratorError(
154+
level=ErrorLevel.ERROR,
155+
header=f"{cmd_name} failed",
156+
detail=err.stderr.decode() or err.output.decode(),
157+
)
158+
)
151159

152-
def _get_errors(self) -> Sequence[GeneratorError]:
153-
errors = []
160+
def _get_errors(self) -> List[GeneratorError]:
161+
errors: List[GeneratorError] = []
154162
for collection in self.openapi.endpoint_collections_by_tag.values():
155163
errors.extend(collection.parse_errors)
156164
errors.extend(self.openapi.errors)
165+
errors.extend(self.errors)
157166
return errors
158167

159168
def _create_package(self) -> None:

openapi_python_client/config.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from pathlib import Path
2-
from typing import Dict, Optional
2+
from typing import Dict, List, Optional
33

44
import yaml
55
from pydantic import BaseModel
@@ -25,6 +25,11 @@ class Config(BaseModel):
2525
project_name_override: Optional[str]
2626
package_name_override: Optional[str]
2727
package_version_override: Optional[str]
28+
post_hooks: List[str] = [
29+
"autoflake -i -r --remove-all-unused-imports --remove-unused-variables --ignore-init-module-imports .",
30+
"isort .",
31+
"black .",
32+
]
2833
field_prefix: str = "field_"
2934

3035
@staticmethod

tests/test___init__.py

+52-43
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66
import yaml
77

8-
from openapi_python_client import Config, GeneratorError
8+
from openapi_python_client import Config, ErrorLevel, GeneratorError, Project
99

1010

1111
def test__get_project_for_url_or_path(mocker):
@@ -241,6 +241,17 @@ def make_project(**kwargs):
241241
return Project(**kwargs)
242242

243243

244+
@pytest.fixture
245+
def project_with_dir() -> Project:
246+
"""Return a Project with the project dir pre-made (needed for cwd of commands). Unlinks after the test completes"""
247+
project = make_project()
248+
project.project_dir.mkdir()
249+
250+
yield project
251+
252+
project.project_dir.rmdir()
253+
254+
244255
class TestProject:
245256
def test___init__(self, mocker):
246257
openapi = mocker.MagicMock(title="My Test API")
@@ -303,7 +314,7 @@ def test_build(self, mocker):
303314
project._build_models = mocker.MagicMock()
304315
project._build_api = mocker.MagicMock()
305316
project._create_package = mocker.MagicMock()
306-
project._reformat = mocker.MagicMock()
317+
project._run_post_hooks = mocker.MagicMock()
307318
project._get_errors = mocker.MagicMock()
308319

309320
result = project.build()
@@ -313,7 +324,7 @@ def test_build(self, mocker):
313324
project._build_metadata.assert_called_once()
314325
project._build_models.assert_called_once()
315326
project._build_api.assert_called_once()
316-
project._reformat.assert_called_once()
327+
project._run_post_hooks.assert_called_once()
317328
project._get_errors.assert_called_once()
318329
assert result == project._get_errors.return_value
319330

@@ -327,7 +338,7 @@ def test_build_no_meta(self, mocker):
327338
project._build_models = mocker.MagicMock()
328339
project._build_api = mocker.MagicMock()
329340
project._create_package = mocker.MagicMock()
330-
project._reformat = mocker.MagicMock()
341+
project._run_post_hooks = mocker.MagicMock()
331342
project._get_errors = mocker.MagicMock()
332343

333344
project.build()
@@ -354,7 +365,7 @@ def test_update(self, mocker):
354365
project._build_models = mocker.MagicMock()
355366
project._build_api = mocker.MagicMock()
356367
project._create_package = mocker.MagicMock()
357-
project._reformat = mocker.MagicMock()
368+
project._run_post_hooks = mocker.MagicMock()
358369
project._get_errors = mocker.MagicMock()
359370

360371
result = project.update()
@@ -363,7 +374,7 @@ def test_update(self, mocker):
363374
project._create_package.assert_called_once()
364375
project._build_models.assert_called_once()
365376
project._build_api.assert_called_once()
366-
project._reformat.assert_called_once()
377+
project._run_post_hooks.assert_called_once()
367378
project._get_errors.assert_called_once()
368379
assert result == project._get_errors.return_value
369380

@@ -501,44 +512,42 @@ def test__build_setup_py(self, mocker):
501512
setup_template.render.assert_called_once_with()
502513
setup_path.write_text.assert_called_once_with(setup_template.render(), encoding="utf-8")
503514

515+
def test__run_post_hooks_reports_missing_commands(self, project_with_dir):
516+
fake_command_name = "blahblahdoesntexist"
517+
project_with_dir.config.post_hooks = [fake_command_name]
518+
need_to_make_cwd = not project_with_dir.project_dir.exists()
519+
if need_to_make_cwd:
520+
project_with_dir.project_dir.mkdir()
504521

505-
def test__reformat(mocker):
506-
import subprocess
522+
project_with_dir._run_post_hooks()
507523

508-
sub_run = mocker.patch("subprocess.run")
509-
project = make_project()
510-
project.project_dir = mocker.MagicMock(autospec=pathlib.Path)
511-
512-
project._reformat()
513-
514-
sub_run.assert_has_calls(
515-
[
516-
mocker.call(
517-
"autoflake -i -r --remove-all-unused-imports --remove-unused-variables --ignore-init-module-imports .",
518-
cwd=project.package_dir,
519-
shell=True,
520-
stdout=subprocess.PIPE,
521-
stderr=subprocess.PIPE,
522-
check=True,
523-
),
524-
mocker.call(
525-
"isort .",
526-
cwd=project.project_dir,
527-
shell=True,
528-
stdout=subprocess.PIPE,
529-
stderr=subprocess.PIPE,
530-
check=True,
531-
),
532-
mocker.call(
533-
"black .",
534-
cwd=project.project_dir,
535-
shell=True,
536-
stdout=subprocess.PIPE,
537-
stderr=subprocess.PIPE,
538-
check=True,
539-
),
540-
]
541-
)
524+
assert len(project_with_dir.errors) == 1
525+
error = project_with_dir.errors[0]
526+
assert error.level == ErrorLevel.WARNING
527+
assert error.header == "Skipping Integration"
528+
assert fake_command_name in error.detail
529+
530+
def test__run_post_hooks_reports_stdout_of_commands_that_error_with_no_stderr(self, project_with_dir):
531+
failing_command = "python -c \"print('a message'); exit(1)\""
532+
project_with_dir.config.post_hooks = [failing_command]
533+
project_with_dir._run_post_hooks()
534+
535+
assert len(project_with_dir.errors) == 1
536+
error = project_with_dir.errors[0]
537+
assert error.level == ErrorLevel.ERROR
538+
assert error.header == "python failed"
539+
assert "a message" in error.detail
540+
541+
def test__run_post_hooks_reports_stderr_of_commands_that_error(self, project_with_dir):
542+
failing_command = "python -c \"print('a message'); raise Exception('some exception')\""
543+
project_with_dir.config.post_hooks = [failing_command]
544+
project_with_dir._run_post_hooks()
545+
546+
assert len(project_with_dir.errors) == 1
547+
error = project_with_dir.errors[0]
548+
assert error.level == ErrorLevel.ERROR
549+
assert error.header == "python failed"
550+
assert "some exception" in error.detail
542551

543552

544553
def test__get_errors(mocker):
@@ -559,7 +568,7 @@ def test__get_errors(mocker):
559568
assert project._get_errors() == [1, 2, 3]
560569

561570

562-
def test__custom_templates(mocker):
571+
def test_custom_templates(mocker):
563572
from openapi_python_client import GeneratorData, MetaType, Project
564573

565574
openapi = mocker.MagicMock(

0 commit comments

Comments
 (0)