Skip to content

Commit dac9b25

Browse files
committed
Added config file option
- Allows overriding class names, closing #9
1 parent 87105b3 commit dac9b25

File tree

19 files changed

+149
-25
lines changed

19 files changed

+149
-25
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,37 @@ get an error.
5151
for endpoints without a tag. Each of these modules in turn contains one function for calling each endpoint.
5252
1. A `models` module which has all the classes defined by the various schemas in your OpenAPI spec
5353

54+
For a full example you can look at tests/test_end_to_end which has a declared [FastAPI](https://fastapi.tiangolo.com/)
55+
server and the resulting openapi.json file in the "fastapi" directory. "golden-master" is the generated client from that
56+
OpenAPI document.
57+
5458
## OpenAPI features supported
5559
1. All HTTP Methods
5660
1. JSON and form bodies, path and query parameters
5761
1. float, string, int, datetimes, string enums, and custom schemas or lists containing any of those
5862
1. html/text or application/json responses containing any of the previous types
5963
1. Bearer token security
6064

65+
## Configuration
66+
You can pass a YAML (or JSON) file to openapi-python-client in order to change some behavior. The following parameters
67+
are supported:
68+
69+
### class_overrides
70+
Used to change the name of generated model classes, especially useful if you have a name like ABCModel which, when
71+
converted to snake case for module naming will be a_b_c_model. This param should be a mapping of existing class name
72+
(usually a key in the "schemas" section of your OpenAPI document) to class_name and module_name.
73+
74+
Example:
75+
```yaml
76+
class_overrides:
77+
ABCModel:
78+
class_name: ABCModel
79+
module_name: abc_model
80+
```
81+
82+
The easiest way to find what needs to be overridden is probably to generate your client and go look at everything in the
83+
models folder.
84+
6185
6286
## Contributors
6387
- Dylan Anthony <[email protected]> (Owner)

openapi_python_client/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import Any, Dict, Optional
1010

1111
import httpx
12+
import yaml
1213
from jinja2 import Environment, PackageLoader
1314

1415
from .openapi_parser import OpenAPI, import_string_from_reference
@@ -22,6 +23,17 @@ def _get_project_for_url_or_path(url: Optional[str], path: Optional[Path]) -> _P
2223
return _Project(openapi=openapi)
2324

2425

26+
def load_config(*, path: Path) -> None:
27+
""" Loads config from provided Path """
28+
config_data = yaml.safe_load(path.read_text())
29+
30+
if "class_overrides" in config_data:
31+
from .openapi_parser import reference
32+
33+
for class_name, class_data in config_data["class_overrides"].items():
34+
reference.class_overrides[class_name] = reference.Reference(**class_data)
35+
36+
2537
def create_new_client(*, url: Optional[str], path: Optional[Path]) -> None:
2638
""" Generate the client library """
2739
project = _get_project_for_url_or_path(url=url, path=path)

openapi_python_client/cli.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,25 @@ def _version_callback(value: bool) -> None:
1414
raise typer.Exit()
1515

1616

17+
def _process_config(path: Optional[pathlib.Path]) -> None:
18+
from openapi_python_client import load_config
19+
20+
if not path:
21+
return
22+
23+
try:
24+
load_config(path=path)
25+
except:
26+
raise typer.BadParameter("Unable to parse config")
27+
28+
1729
# noinspection PyUnusedLocal
1830
@app.callback(name="openapi-python-client")
1931
def cli(
2032
version: bool = typer.Option(False, "--version", callback=_version_callback, help="Print the version and exit"),
33+
config: Optional[pathlib.Path] = typer.Option(
34+
None, callback=_process_config, help="Path to the config file to use"
35+
),
2136
) -> None:
2237
""" Generate a Python client from an OpenAPI JSON document """
2338
pass

openapi_python_client/openapi_parser/reference.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6+
from typing import Dict
67

78
import stringcase
89

10+
class_overrides: Dict[str, Reference] = {}
11+
912

1013
@dataclass
1114
class Reference:
@@ -18,4 +21,9 @@ class Reference:
1821
def from_ref(ref: str) -> Reference:
1922
""" Get a Reference from the openapi #/schemas/blahblah string """
2023
ref_value = ref.split("/")[-1]
21-
return Reference(class_name=stringcase.pascalcase(ref_value), module_name=stringcase.snakecase(ref_value),)
24+
class_name = stringcase.pascalcase(ref_value)
25+
26+
if class_name in class_overrides:
27+
return class_overrides[class_name]
28+
29+
return Reference(class_name=class_name, module_name=stringcase.snakecase(ref_value),)

poetry.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ shellingham = "^1.3.2"
2424
httpx = "^0.12.1"
2525
black = "^19.10b0"
2626
isort = "^4.3.21"
27+
pyyaml = "^5.3.1"
2728

2829
[tool.poetry.scripts]
2930
openapi-python-client = "openapi_python_client.cli:app"

tests/test___init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,21 @@ def test__reformat(mocker):
437437
mocker.call("black .", cwd=project.project_dir, shell=True),
438438
]
439439
)
440+
441+
442+
def test_load_config(mocker):
443+
my_data = {"class_overrides": {"_MyCLASSName": {"class_name": "MyClassName", "module_name": "my_module_name"}}}
444+
safe_load = mocker.patch("yaml.safe_load", return_value=my_data)
445+
fake_path = mocker.MagicMock(autospec=pathlib.Path)
446+
447+
from openapi_python_client import load_config
448+
449+
load_config(path=fake_path)
450+
451+
fake_path.read_text.assert_called_once()
452+
safe_load.assert_called_once_with(fake_path.read_text())
453+
from openapi_python_client.openapi_parser import reference
454+
455+
assert reference.class_overrides == {
456+
"_MyCLASSName": reference.Reference(class_name="MyClassName", module_name="my_module_name")
457+
}

tests/test_cli.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from pathlib import PosixPath
2+
from unittest.mock import MagicMock
23

34
import pytest
45
from typer.testing import CliRunner
@@ -18,17 +19,46 @@ def test_version(mocker):
1819

1920

2021
@pytest.fixture
21-
def _create_new_client(mocker):
22+
def _create_new_client(mocker) -> MagicMock:
2223
return mocker.patch("openapi_python_client.create_new_client")
2324

2425

26+
def test_config(mocker, _create_new_client):
27+
load_config = mocker.patch("openapi_python_client.load_config")
28+
from openapi_python_client.cli import app
29+
30+
config_path = "config/path"
31+
path = "cool/path"
32+
33+
result = runner.invoke(app, [f"--config={config_path}", "generate", f"--path={path}"], catch_exceptions=False)
34+
35+
assert result.exit_code == 0
36+
load_config.assert_called_once_with(path=PosixPath(config_path))
37+
_create_new_client.assert_called_once_with(url=None, path=PosixPath(path))
38+
39+
40+
def test_bad_config(mocker, _create_new_client):
41+
load_config = mocker.patch("openapi_python_client.load_config", side_effect=ValueError("Bad Config"))
42+
from openapi_python_client.cli import app
43+
44+
config_path = "config/path"
45+
path = "cool/path"
46+
47+
result = runner.invoke(app, [f"--config={config_path}", "generate", f"--path={path}"])
48+
49+
assert result.exit_code == 2
50+
assert "Unable to parse config" in result.stdout
51+
load_config.assert_called_once_with(path=PosixPath(config_path))
52+
_create_new_client.assert_not_called()
53+
54+
2555
class TestGenerate:
2656
def test_generate_no_params(self, _create_new_client):
2757
from openapi_python_client.cli import app
2858

2959
result = runner.invoke(app, ["generate"])
3060

31-
assert result.exit_code == 1
61+
assert result.exit_code == 1, result.output
3262
_create_new_client.assert_not_called()
3363

3464
def test_generate_url_and_path(self, _create_new_client):

tests/test_end_to_end/config.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class_overrides:
2+
_ABCResponse:
3+
class_name: ABCResponse
4+
module_name: abc_response

tests/test_end_to_end/fastapi/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
app = FastAPI(title="My Test API", description="An API for testing openapi-python-client",)
99

1010

11-
class PingResponse(BaseModel):
11+
class _ABCResponse(BaseModel):
1212
success: bool
1313

1414

15-
@app.get("/ping", response_model=PingResponse)
15+
@app.get("/ping", response_model=_ABCResponse)
1616
async def ping():
1717
""" A quick check to see if the system is running """
1818
return {"success": True}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"openapi": "3.0.2", "info": {"title": "My Test API", "description": "An API for testing openapi-python-client", "version": "0.1.0"}, "paths": {"/ping": {"get": {"summary": "Ping", "description": "A quick check to see if the system is running ", "operationId": "ping_ping_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PingResponse"}}}}}}}}, "components": {"schemas": {"PingResponse": {"title": "PingResponse", "required": ["success"], "type": "object", "properties": {"success": {"title": "Success", "type": "boolean"}}}}}}
1+
{"openapi": "3.0.2", "info": {"title": "My Test API", "description": "An API for testing openapi-python-client", "version": "0.1.0"}, "paths": {"/ping": {"get": {"summary": "Ping", "description": "A quick check to see if the system is running ", "operationId": "ping_ping_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_ABCResponse"}}}}}}}}, "components": {"schemas": {"_ABCResponse": {"title": "_ABCResponse", "required": ["success"], "type": "object", "properties": {"success": {"title": "Success", "type": "boolean"}}}}}}

tests/test_end_to_end/golden-master/my_test_api_client/api/default.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,21 @@
44
import httpx
55

66
from ..client import AuthenticatedClient, Client
7-
from ..models.ping_response import PingResponse
7+
from ..models.abc_response import ABCResponse
88
from .errors import ApiResponseError
99

1010

1111
def ping_ping_get(
1212
*, client: Client,
1313
) -> Union[
14-
PingResponse,
14+
ABCResponse,
1515
]:
1616
""" A quick check to see if the system is running """
1717
url = f"{client.base_url}/ping"
1818

1919
response = httpx.get(url=url, headers=client.get_headers(),)
2020

2121
if response.status_code == 200:
22-
return PingResponse.from_dict(response.json())
22+
return ABCResponse.from_dict(response.json())
2323
else:
2424
raise ApiResponseError(response=response)

tests/test_end_to_end/golden-master/my_test_api_client/async_api/default.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
import httpx
55

66
from ..client import AuthenticatedClient, Client
7-
from ..models.ping_response import PingResponse
7+
from ..models.abc_response import ABCResponse
88
from .errors import ApiResponseError
99

1010

1111
async def ping_ping_get(
1212
*, client: Client,
1313
) -> Union[
14-
PingResponse,
14+
ABCResponse,
1515
]:
1616
""" A quick check to see if the system is running """
1717
url = f"{client.base_url}/ping"
@@ -20,6 +20,6 @@ async def ping_ping_get(
2020
response = await client.get(url=url, headers=client.get_headers(),)
2121

2222
if response.status_code == 200:
23-
return PingResponse.from_dict(response.json())
23+
return ABCResponse.from_dict(response.json())
2424
else:
2525
raise ApiResponseError(response=response)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
""" Contains all the data models used in inputs/outputs """
22

3-
from .ping_response import PingResponse
3+
from .abc_response import ABCResponse

tests/test_end_to_end/golden-master/my_test_api_client/models/ping_response.py renamed to tests/test_end_to_end/golden-master/my_test_api_client/models/abc_response.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77

88
@dataclass
9-
class PingResponse:
9+
class ABCResponse:
1010
""" """
1111

1212
success: bool
@@ -17,7 +17,7 @@ def to_dict(self) -> Dict:
1717
}
1818

1919
@staticmethod
20-
def from_dict(d: Dict) -> PingResponse:
20+
def from_dict(d: Dict) -> ABCResponse:
2121

2222
success = d["success"]
23-
return PingResponse(success=success,)
23+
return ABCResponse(success=success,)

tests/test_end_to_end/regen-golden-master.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
runner = CliRunner()
1111
openapi_path = Path(__file__).parent / "fastapi" / "openapi.json"
1212
gm_path = Path(__file__).parent / "golden-master"
13-
shutil.rmtree(gm_path)
13+
shutil.rmtree(gm_path, ignore_errors=True)
1414
output_path = Path.cwd() / "my-test-api-client"
15+
shutil.rmtree(output_path, ignore_errors=True)
16+
config_path = Path(__file__).parent / "config.yml"
1517

16-
runner.invoke(app, ["generate", f"--path={openapi_path}"])
18+
runner.invoke(app, [f"--config={config_path}", "generate", f"--path={openapi_path}"])
1719

1820
output_path.rename(gm_path)

tests/test_end_to_end/test_end_to_end.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ def _compare_directories(first: Path, second: Path, /):
2727
def test_end_to_end(capsys):
2828
runner = CliRunner()
2929
openapi_path = Path(__file__).parent / "fastapi" / "openapi.json"
30+
config_path = Path(__file__).parent / "config.yml"
3031
gm_path = Path(__file__).parent / "golden-master"
3132
output_path = Path.cwd() / "my-test-api-client"
3233

33-
result = runner.invoke(app, ["generate", f"--path={openapi_path}"])
34+
result = runner.invoke(app, [f"--config={config_path}", "generate", f"--path={openapi_path}"])
3435

3536
if result.exit_code != 0:
3637
raise result.exception

tests/test_openapi_parser/test_openapi.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,11 +248,11 @@ def test_add_body_happy(self, mocker):
248248
from openapi_python_client.openapi_parser.openapi import Endpoint, Reference, RefProperty
249249

250250
request_body = mocker.MagicMock()
251-
form_body_reference = Reference(ref="a")
251+
form_body_reference = Reference.from_ref(ref="a")
252252
parse_request_form_body = mocker.patch.object(
253253
Endpoint, "parse_request_form_body", return_value=form_body_reference
254254
)
255-
json_body = RefProperty(name="name", required=True, default=None, reference=Reference("b"))
255+
json_body = RefProperty(name="name", required=True, default=None, reference=Reference.from_ref("b"))
256256
parse_request_json_body = mocker.patch.object(Endpoint, "parse_request_json_body", return_value=json_body)
257257
import_string_from_reference = mocker.patch(
258258
f"{MODULE_NAME}.import_string_from_reference", side_effect=["import_1", "import_2"]
@@ -294,8 +294,8 @@ def test__add_responses(self, mocker):
294294
tag="tag",
295295
relative_imports={"import_3"},
296296
)
297-
ref_1 = Reference(ref="ref_1")
298-
ref_2 = Reference(ref="ref_2")
297+
ref_1 = Reference.from_ref(ref="ref_1")
298+
ref_2 = Reference.from_ref(ref="ref_2")
299299
response_1 = RefResponse(status_code=200, reference=ref_1)
300300
response_2 = RefResponse(status_code=404, reference=ref_2)
301301
response_from_dict = mocker.patch(f"{MODULE_NAME}.response_from_dict", side_effect=[response_1, response_2])

tests/test_openapi_parser/test_reference.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,12 @@ def test_from_ref():
55

66
assert r.class_name == "PingResponse"
77
assert r.module_name == "ping_response"
8+
9+
10+
def test_from_ref_class_overrides():
11+
from openapi_python_client.openapi_parser.reference import Reference, class_overrides
12+
13+
ref = "#/components/schemas/_MyResponse"
14+
class_overrides["_MyResponse"] = Reference(class_name="MyResponse", module_name="my_response")
15+
16+
assert Reference.from_ref(ref) == class_overrides["_MyResponse"]

0 commit comments

Comments
 (0)