Skip to content

Commit

Permalink
Merge pull request #32 from phalt/0.8.0
Browse files Browse the repository at this point in the history
0.8.0 release
  • Loading branch information
phalt authored Oct 25, 2023
2 parents 008ac25 + 49eaafd commit 90dfb36
Show file tree
Hide file tree
Showing 37 changed files with 541 additions and 626 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,12 @@ jobs:
#----------------------------------------------
# Do the actuall checks
#----------------------------------------------
- name: Black check
- name: Ruff format
run: |
poetry run black clientele/ --check
- name: Ruff check
poetry run ruff format --check .
- name: Ruff linting
run: |
poetry run ruff clientele/
poetry run ruff .
- name: Test with pytest
run: |
poetry run pytest -vv
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Change log

## 0.8.0

- Improved support for Async clients which prevents a weird bug when running more than one event loop. Based on the suggestions from [this httpx issue](https://github.com/encode/httpcore/discussions/659).
- We now use [`ruff format`](https://astral.sh/blog/the-ruff-formatter) for coding formatting (not the client output).
- `Decimal` support now extends to Decimal input values.
- Input and Output schemas will now have properties that directly match those provided by the OpenAPI schema. This fixes a bug where previously, the snake-case formatting did not match up with what the API expected to send or receive.

## 0.7.1

- Support for `Decimal` types.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Check your `git diff` to see if anything drastic has changed. If changes happen
Format and lint the code:

```sh
make lint
make format
```

Note that, the auto-generated black formatted code will be changed again because this project uses `ruff` for additional formatting. That's okay.
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ release: ## Build a new version and release it
mypy: ## Run a static syntax check
poetry run mypy .

lint: ## Format the code correctly
poetry run black .
format: ## Format the code correctly
poetry run ruff format .
poetry run ruff --fix .

clean: ## Clear any cache files and test files
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ match response:
...
```

The generated code is tiny - the [example schema](https://github.com/phalt/clientele/blob/0.4.4/example_openapi_specs/best.json) we use for documentation and testing only requires [250 lines of code](https://github.com/phalt/clientele/tree/0.4.4/tests/test_client) and 5 files.
The generated code is tiny - the [example schema](https://github.com/phalt/clientele/blob/main/example_openapi_specs/best.json) we use for documentation and testing only requires [250 lines of code](https://github.com/phalt/clientele/tree/main/tests/test_client) and 5 files.

## Async support

Expand Down
36 changes: 9 additions & 27 deletions clientele/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,26 +49,18 @@ def validate(url, file):
else:
with open(file, "r") as f:
Spec.from_file(f)
console.log(
f"Found API specification: {spec['info']['title']} | version {spec['info']['version']}"
)
console.log(f"Found API specification: {spec['info']['title']} | version {spec['info']['version']}")
major, _, _ = spec["openapi"].split(".")
if int(major) < 3:
console.log(
f"[red]Clientele only supports OpenAPI version 3.0.0 and up, and you have {spec['openapi']}"
)
console.log(f"[red]Clientele only supports OpenAPI version 3.0.0 and up, and you have {spec['openapi']}")
return
console.log("schema validated successfully! You can generate a client with it")


@click.command()
@click.option("-u", "--url", help="URL to openapi schema (URL)", required=False)
@click.option(
"-f", "--file", help="Path to openapi schema (json or yaml file)", required=False
)
@click.option(
"-o", "--output", help="Directory for the generated client", required=True
)
@click.option("-f", "--file", help="Path to openapi schema (json or yaml file)", required=False)
@click.option("-o", "--output", help="Directory for the generated client", required=True)
@click.option("-a", "--asyncio", help="Generate async client", required=False)
@click.option("-r", "--regen", help="Regenerate client", required=False)
def generate(url, file, output, asyncio, regen):
Expand Down Expand Up @@ -100,30 +92,20 @@ def generate(url, file, output, asyncio, regen):
else:
with open(file, "r") as f:
spec = Spec.from_file(f)
console.log(
f"Found API specification: {spec['info']['title']} | version {spec['info']['version']}"
)
console.log(f"Found API specification: {spec['info']['title']} | version {spec['info']['version']}")
major, _, _ = spec["openapi"].split(".")
if int(major) < 3:
console.log(
f"[red]Clientele only supports OpenAPI version 3.0.0 and up, and you have {spec['openapi']}"
)
console.log(f"[red]Clientele only supports OpenAPI version 3.0.0 and up, and you have {spec['openapi']}")
return
generator = StandardGenerator(
spec=spec, asyncio=asyncio, regen=regen, output_dir=output, url=url, file=file
)
generator = StandardGenerator(spec=spec, asyncio=asyncio, regen=regen, output_dir=output, url=url, file=file)
if generator.prevent_accidental_regens():
generator.generate()
console.log("\n[green]⚜️ Client generated! ⚜️ \n")
console.log(
"[yellow]REMEMBER: install `httpx` `pydantic`, and `respx` to use your new client"
)
console.log("[yellow]REMEMBER: install `httpx` `pydantic`, and `respx` to use your new client")


@click.command()
@click.option(
"-o", "--output", help="Directory for the generated client", required=True
)
@click.option("-o", "--output", help="Directory for the generated client", required=True)
def generate_basic(output):
"""
Generate a "basic" file structure, no code.
Expand Down
12 changes: 3 additions & 9 deletions clientele/generators/basic/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,12 @@ def __init__(self, output_dir: str) -> None:
)

def generate(self) -> None:
client_project_directory_path = utils.get_client_project_directory_path(
output_dir=self.output_dir
)
client_project_directory_path = utils.get_client_project_directory_path(output_dir=self.output_dir)
if exists(f"{self.output_dir}/MANIFEST.md"):
remove(f"{self.output_dir}/MANIFEST.md")
manifest_template = writer.templates.get_template("manifest.jinja2")
manifest_content = manifest_template.render(
command=f"-o {self.output_dir}", clientele_version=settings.VERSION
)
writer.write_to_manifest(
content=manifest_content + "\n", output_dir=self.output_dir
)
manifest_content = manifest_template.render(command=f"-o {self.output_dir}", clientele_version=settings.VERSION)
writer.write_to_manifest(content=manifest_content + "\n", output_dir=self.output_dir)
writer.write_to_init(output_dir=self.output_dir)
for (
client_file,
Expand Down
4 changes: 1 addition & 3 deletions clientele/generators/basic/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

from jinja2 import Environment, PackageLoader

templates = Environment(
loader=PackageLoader("clientele", "generators/basic/templates/")
)
templates = Environment(loader=PackageLoader("clientele", "generators/basic/templates/"))


def write_to_schemas(content: str, output_dir: str) -> None:
Expand Down
20 changes: 5 additions & 15 deletions clientele/generators/standard/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,8 @@ def __init__(
url: Optional[str],
file: Optional[str],
) -> None:
self.http_generator = http.HTTPGenerator(
spec=spec, output_dir=output_dir, asyncio=asyncio
)
self.schemas_generator = schemas.SchemasGenerator(
spec=spec, output_dir=output_dir
)
self.http_generator = http.HTTPGenerator(spec=spec, output_dir=output_dir, asyncio=asyncio)
self.schemas_generator = schemas.SchemasGenerator(spec=spec, output_dir=output_dir)
self.clients_generator = clients.ClientsGenerator(
spec=spec,
output_dir=output_dir,
Expand All @@ -68,9 +64,7 @@ def __init__(

def generate_templates_files(self):
new_unions = settings.PY_VERSION[1] > 10
client_project_directory_path = utils.get_client_project_directory_path(
output_dir=self.output_dir
)
client_project_directory_path = utils.get_client_project_directory_path(output_dir=self.output_dir)
writer.write_to_init(output_dir=self.output_dir)
for (
client_file,
Expand Down Expand Up @@ -104,18 +98,14 @@ def generate_templates_files(self):
def prevent_accidental_regens(self) -> bool:
if exists(self.output_dir):
if not self.regen:
console.log(
"[red]WARNING! If you want to regenerate, please pass --regen t"
)
console.log("[red]WARNING! If you want to regenerate, please pass --regen t")
return False
return True

def format_client(self) -> None:
directory = Path(self.output_dir)
for f in directory.glob("*.py"):
black.format_file_in_place(
f, fast=False, mode=black.Mode(), write_back=black.WriteBack.YES
)
black.format_file_in_place(f, fast=False, mode=black.Mode(), write_back=black.WriteBack.YES)

def generate(self) -> None:
self.generate_templates_files()
Expand Down
40 changes: 10 additions & 30 deletions clientele/generators/standard/generators/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,7 @@ def generate_paths(self) -> None:
console.log(f"Generated {self.results['put']} PUT methods...")
console.log(f"Generated {self.results['delete']} DELETE methods...")

def generate_parameters(
self, parameters: list[dict], additional_parameters: list[dict]
) -> ParametersResponse:
def generate_parameters(self, parameters: list[dict], additional_parameters: list[dict]) -> ParametersResponse:
param_keys = []
query_args = {}
path_args = {}
Expand All @@ -88,17 +86,13 @@ def generate_parameters(
if required:
query_args[clean_key] = utils.get_type(param["schema"])
else:
query_args[
clean_key
] = f"typing.Optional[{utils.get_type(param['schema'])}]"
query_args[clean_key] = f"typing.Optional[{utils.get_type(param['schema'])}]"
elif in_ == "path":
# Function arguments
if required:
path_args[clean_key] = utils.get_type(param["schema"])
else:
path_args[
clean_key
] = f"typing.Optional[{utils.get_type(param['schema'])}]"
path_args[clean_key] = f"typing.Optional[{utils.get_type(param['schema'])}]"
elif in_ == "header":
# Header object arguments
headers_args[param["name"]] = utils.get_type(param["schema"])
Expand Down Expand Up @@ -129,25 +123,19 @@ def get_response_class_names(self, responses: dict, func_name: str) -> list[str]
# This usually means we have an object that isn't
# $ref so we need to create the schema class here
class_name = utils.class_name_titled(title)
self.schemas_generator.make_schema_class(
class_name, schema=content["schema"]
)
self.schemas_generator.make_schema_class(class_name, schema=content["schema"])
else:
# At this point we're just making things up!
# It is likely it isn't an object it is just a simple resonse.
class_name = utils.class_name_titled(
func_name + status_code + "Response"
)
class_name = utils.class_name_titled(func_name + status_code + "Response")
# We need to generate the class at this point because it does not exist
self.schemas_generator.make_schema_class(
func_name + status_code + "Response",
schema={"properties": {"test": content["schema"]}},
)
status_code_map[status_code] = class_name
response_classes.append(class_name)
self.http_generator.add_status_codes_to_bundle(
func_name=func_name, status_code_map=status_code_map
)
self.http_generator.add_status_codes_to_bundle(func_name=func_name, status_code_map=status_code_map)
return sorted(list(set(response_classes)))

def get_input_class_names(self, inputs: dict) -> list[str]:
Expand All @@ -170,13 +158,9 @@ def get_input_class_names(self, inputs: dict) -> list[str]:
return list(set(input_classes))

def generate_response_types(self, responses: dict, func_name: str) -> str:
response_class_names = self.get_response_class_names(
responses=responses, func_name=func_name
)
response_class_names = self.get_response_class_names(responses=responses, func_name=func_name)
if len(response_class_names) > 1:
return utils.union_for_py_ver(
[f"schemas.{r}" for r in response_class_names]
)
return utils.union_for_py_ver([f"schemas.{r}" for r in response_class_names])
elif len(response_class_names) == 0:
return "None"
else:
Expand Down Expand Up @@ -204,9 +188,7 @@ def generate_function(
summary: Optional[str],
):
func_name = utils.get_func_name(operation, url)
response_types = self.generate_response_types(
responses=operation["responses"], func_name=func_name
)
response_types = self.generate_response_types(responses=operation["responses"], func_name=func_name)
function_arguments = self.generate_parameters(
parameters=operation.get("parameters", []),
additional_parameters=additional_parameters,
Expand All @@ -218,9 +200,7 @@ def generate_function(
if method in ["post", "put"] and not operation.get("requestBody"):
data_class_name = "None"
elif method in ["post", "put"]:
data_class_name = self.generate_input_types(
operation.get("requestBody", {})
)
data_class_name = self.generate_input_types(operation.get("requestBody", {}))
else:
data_class_name = None
self.results[method] += 1
Expand Down
12 changes: 3 additions & 9 deletions clientele/generators/standard/generators/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ def __init__(self, spec: Spec, output_dir: str, asyncio: bool) -> None:
self.asyncio = asyncio
self.function_and_status_codes_bundle: dict[str, dict[str, str]] = {}

def add_status_codes_to_bundle(
self, func_name: str, status_code_map: dict[str, str]
) -> None:
def add_status_codes_to_bundle(self, func_name: str, status_code_map: dict[str, str]) -> None:
"""
Build a huge map of each function and it's status code responses.
At the end of the client generation you should call http_generator.generate_http_content()
Expand All @@ -38,9 +36,7 @@ def writeable_function_and_status_codes_bundle(self) -> str:
return f"\nfunc_response_code_maps = {self.function_and_status_codes_bundle}"

def generate_http_content(self) -> None:
writer.write_to_http(
self.writeable_function_and_status_codes_bundle(), self.output_dir
)
writer.write_to_http(self.writeable_function_and_status_codes_bundle(), self.output_dir)
client_generated = False
client_type = "AsyncClient" if self.asyncio else "Client"
if security_schemes := self.spec["components"].get("securitySchemes"):
Expand All @@ -62,9 +58,7 @@ def generate_http_content(self) -> None:
content = template.render(
client_type=client_type,
)
console.log(
f"[yellow]Please see {self.output_dir}config.py to set authentication variables"
)
console.log(f"[yellow]Please see {self.output_dir}config.py to set authentication variables")
elif info["type"] == "oauth2":
template = writer.templates.get_template("bearer_client.jinja2")
content = template.render(
Expand Down
Loading

0 comments on commit 90dfb36

Please sign in to comment.