Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved async #31

Merged
merged 2 commits into from
Oct 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# 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).

## 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