Skip to content

Remove update, add --overwrite and --output-path #1052

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

Merged
merged 6 commits into from
Jun 8, 2024
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
9 changes: 9 additions & 0 deletions .changeset/added_an_output_path_option_to_generate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
default: minor
---

# Added an `--output-path` option to `generate`

Rather than changing directories before running `generate` you can now specify an output directory with `--output-path`.
Note that the project name will _not_ be appended to the `--output-path`, whatever path you specify is where the
generated code will be placed.
8 changes: 8 additions & 0 deletions .changeset/added_an_overwrite_flag_to_generate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
default: minor
---

# Added an `--overwrite` flag to `generate`

You can now tell `openapi-python-client` to overwrite an existing directory, rather than deleting it yourself before
running `generate`.
18 changes: 18 additions & 0 deletions .changeset/remove_the_update_command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
default: major
---

# Removed the `update` command

The `update` command is no more, you can (mostly) replace its usage with some new flags on the `generate` command.

If you had a package named `my-api-client` in the current working directory, the `update` command previously would update the `my_api_client` module within it. You can now _almost_ perfectly replicate this behavior using `openapi-python-client generate --meta=none --output-path=my-api-client/my_api_client --overwrite`.

The only difference is that `my-api-client` would have run `post_hooks` in the `my-api-client` directory,
but `generate` will run `post_hooks` in the `output-path` directory.

Alternatively, you can now also run `openapi-python-client generate --meta=<your-meta-type> --overwrite` to regenerate
the entire client, if you don't care about keeping any changes you've made to the generated client.

Please comment on [discussion #824](https://github.com/openapi-generators/openapi-python-client/discussions/824)
(or a new discussion, as appropriate) to aid in designing future features that fill any gaps this leaves for you.
3 changes: 0 additions & 3 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,6 @@ jobs:
- name: Check formatting
run: pdm run ruff format . --check

- name: Run safety
run: pdm safety_check

- name: Run mypy
run: pdm mypy --show-error-codes

Expand Down
19 changes: 19 additions & 0 deletions end_to_end_tests/invalid_openapi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
openapi: "3.1.0"
info:
title: "There's something wrong with me"
version: "0.1.0"
paths:
"/{optional}":
get:
parameters:
- in: "path"
name: "optional"
schema:
type: "string"
responses:
"200":
description: "Successful Response"
content:
"application/json":
schema:
const: "Why have a fixed response? I dunno"
47 changes: 26 additions & 21 deletions end_to_end_tests/test_end_to_end.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,16 +215,21 @@ def test_custom_templates():
)


@pytest.mark.parametrize(
"command", ("generate", "update")
)
def test_bad_url(command: str):
def test_bad_url():
runner = CliRunner()
result = runner.invoke(app, [command, "--url=not_a_url"])
result = runner.invoke(app, ["generate", "--url=not_a_url"])
assert result.exit_code == 1
assert "Could not get OpenAPI document from provided URL" in result.stdout


def test_invalid_document():
runner = CliRunner()
path = Path(__file__).parent / "invalid_openapi.yaml"
result = runner.invoke(app, ["generate", f"--path={path}", "--fail-on-warning"])
assert result.exit_code == 1
assert "Warning(s) encountered while generating" in result.stdout


def test_custom_post_hooks():
shutil.rmtree(Path.cwd() / "my-test-api-client", ignore_errors=True)
runner = CliRunner()
Expand All @@ -248,16 +253,6 @@ def test_generate_dir_already_exists():
shutil.rmtree(Path.cwd() / "my-test-api-client", ignore_errors=True)


def test_update_dir_not_found():
project_dir = Path.cwd() / "my-test-api-client"
shutil.rmtree(project_dir, ignore_errors=True)
runner = CliRunner()
openapi_document = Path(__file__).parent / "baseline_openapi_3.0.json"
result = runner.invoke(app, ["update", f"--path={openapi_document}"])
assert result.exit_code == 1
assert str(project_dir) in result.stdout


@pytest.mark.parametrize(
("file_name", "content", "expected_error"),
(
Expand All @@ -280,9 +275,19 @@ def test_invalid_openapi_document(file_name, content, expected_error):
def test_update_integration_tests():
url = "https://raw.githubusercontent.com/openapi-generators/openapi-test-server/main/openapi.json"
source_path = Path(__file__).parent.parent / "integration-tests"
project_path = Path.cwd() / "integration-tests"
if source_path != project_path: # Just in case someone runs this from root dir
shutil.copytree(source_path, project_path)
config_path = project_path / "config.yaml"
_run_command("update", url=url, config_path=config_path)
_compare_directories(source_path, project_path, expected_differences={})
temp_dir = Path.cwd() / "test_update_integration_tests"
shutil.rmtree(temp_dir, ignore_errors=True)
shutil.copytree(source_path, temp_dir)
config_path = source_path / "config.yaml"
_run_command(
"generate",
extra_args=["--meta=none", "--overwrite", f"--output-path={source_path / 'integration_tests'}"],
url=url,
config_path=config_path
)
_compare_directories(temp_dir, source_path, expected_differences={})
import mypy.api

out, err, status = mypy.api.run([str(temp_dir), "--strict"])
assert status == 0, f"Type checking client failed: {out}"
shutil.rmtree(temp_dir)
3 changes: 1 addition & 2 deletions integration-tests/config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
project_name_override: integration-tests
post_hooks:
- ruff check . --fix
- ruff format .
- mypy . --strict
- ruff format .
1 change: 0 additions & 1 deletion integration-tests/integration_tests/py.typed

This file was deleted.

76 changes: 27 additions & 49 deletions openapi_python_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,22 @@ def __init__(
)

self.project_name: str = config.project_name_override or f"{utils.kebab_case(openapi.title).lower()}-client"
self.project_dir: Path = Path.cwd()
if config.meta_type != MetaType.NONE:
self.project_dir /= self.project_name

self.package_name: str = config.package_name_override or self.project_name.replace("-", "_")
self.package_dir: Path = self.project_dir / self.package_name
self.project_dir: Path # Where the generated code will be placed
self.package_dir: Path # Where the generated Python module will be placed (same as project_dir if no meta)

if config.output_path is not None:
self.project_dir = config.output_path
elif config.meta_type == MetaType.NONE:
self.project_dir = Path.cwd() / self.package_name
else:
self.project_dir = Path.cwd() / self.project_name

if config.meta_type == MetaType.NONE:
self.package_dir = self.project_dir
else:
self.package_dir = self.project_dir / self.package_name

self.package_description: str = utils.remove_string_escapes(
f"A client library for accessing {self.openapi.title}"
)
Expand All @@ -95,29 +105,16 @@ def __init__(
def build(self) -> Sequence[GeneratorError]:
"""Create the project from templates"""

if self.config.meta_type == MetaType.NONE:
print(f"Generating {self.package_name}")
else:
print(f"Generating {self.project_name}")
try:
self.project_dir.mkdir()
except FileExistsError:
return [GeneratorError(detail="Directory already exists. Delete it or use the update command.")]
self._create_package()
self._build_metadata()
self._build_models()
self._build_api()
self._run_post_hooks()
return self._get_errors()

def update(self) -> Sequence[GeneratorError]:
"""Update an existing project"""
print(f"Generating {self.project_dir}")
if self.config.overwrite:
shutil.rmtree(self.project_dir, ignore_errors=True)

if not self.package_dir.is_dir():
return [GeneratorError(detail=f"Directory {self.package_dir} not found")]
print(f"Updating {self.package_name}")
shutil.rmtree(self.package_dir)
try:
self.project_dir.mkdir()
except FileExistsError:
return [GeneratorError(detail="Directory already exists. Delete it or use the --overwrite option.")]
self._create_package()
self._build_metadata()
self._build_models()
self._build_api()
self._run_post_hooks()
Expand All @@ -138,7 +135,7 @@ def _run_command(self, cmd: str) -> None:
)
return
try:
cwd = self.package_dir if self.config.meta_type == MetaType.NONE else self.project_dir
cwd = self.project_dir
subprocess.run(cmd, cwd=cwd, shell=True, capture_output=True, check=True)
except CalledProcessError as err:
self.errors.append(
Expand All @@ -158,7 +155,8 @@ def _get_errors(self) -> List[GeneratorError]:
return errors

def _create_package(self) -> None:
self.package_dir.mkdir()
if self.package_dir != self.project_dir:
self.package_dir.mkdir()
# Package __init__.py
package_init = self.package_dir / "__init__.py"

Expand Down Expand Up @@ -303,7 +301,7 @@ def _get_project_for_url_or_path(
)


def create_new_client(
def generate(
*,
config: Config,
custom_template_path: Optional[Path] = None,
Expand All @@ -323,26 +321,6 @@ def create_new_client(
return project.build()


def update_existing_client(
*,
config: Config,
custom_template_path: Optional[Path] = None,
) -> Sequence[GeneratorError]:
"""
Update an existing client library

Returns:
A list containing any errors encountered when generating.
"""
project = _get_project_for_url_or_path(
custom_template_path=custom_template_path,
config=config,
)
if isinstance(project, GeneratorError):
return [project]
return project.update()


def _load_yaml_or_json(data: bytes, content_type: Optional[str]) -> Union[Dict[str, Any], GeneratorError]:
if content_type == "application/json":
try:
Expand Down
93 changes: 42 additions & 51 deletions openapi_python_client/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ def _version_callback(value: bool) -> None:


def _process_config(
*, url: Optional[str], path: Optional[Path], config_path: Optional[Path], meta_type: MetaType, file_encoding: str
*,
url: Optional[str],
path: Optional[Path],
config_path: Optional[Path],
meta_type: MetaType,
file_encoding: str,
overwrite: bool,
output_path: Optional[Path],
) -> Config:
source: Union[Path, str]
if url and not path:
Expand Down Expand Up @@ -49,7 +56,7 @@ def _process_config(
except Exception as err:
raise typer.BadParameter("Unable to parse config") from err

return Config.from_sources(config_file, meta_type, source, file_encoding)
return Config.from_sources(config_file, meta_type, source, file_encoding, overwrite, output_path=output_path)


# noinspection PyUnusedLocal
Expand Down Expand Up @@ -117,62 +124,46 @@ def handle_errors(errors: Sequence[GeneratorError], fail_on_warning: bool = Fals
raise typer.Exit(code=1)


custom_template_path_options = {
"help": "A path to a directory containing custom template(s)",
"file_okay": False,
"dir_okay": True,
"readable": True,
"resolve_path": True,
}

_meta_option = typer.Option(
MetaType.POETRY,
help="The type of metadata you want to generate.",
)

CONFIG_OPTION = typer.Option(None, "--config", help="Path to the config file to use")


@app.command()
def generate(
url: Optional[str] = typer.Option(None, help="A URL to read the JSON from"),
path: Optional[Path] = typer.Option(None, help="A path to the JSON file"),
custom_template_path: Optional[Path] = typer.Option(None, **custom_template_path_options), # type: ignore
meta: MetaType = _meta_option,
url: Optional[str] = typer.Option(None, help="A URL to read the OpenAPI document from"),
path: Optional[Path] = typer.Option(None, help="A path to the OpenAPI document"),
custom_template_path: Optional[Path] = typer.Option(
None,
help="A path to a directory containing custom template(s)",
file_okay=False,
dir_okay=True,
readable=True,
resolve_path=True,
), # type: ignore
meta: MetaType = typer.Option(
MetaType.POETRY,
help="The type of metadata you want to generate.",
),
file_encoding: str = typer.Option("utf-8", help="Encoding used when writing generated"),
config_path: Optional[Path] = CONFIG_OPTION,
config_path: Optional[Path] = typer.Option(None, "--config", help="Path to the config file to use"),
fail_on_warning: bool = False,
overwrite: bool = typer.Option(False, help="Overwrite the existing client if it exists"),
output_path: Optional[Path] = typer.Option(
None,
help="Path to write the generated code to. "
"Defaults to the OpenAPI document title converted to kebab or snake case (depending on meta type). "
"Can also be overridden with `project_name_override` or `package_name_override` in config.",
),
) -> None:
"""Generate a new OpenAPI Client library"""
from . import create_new_client

config = _process_config(url=url, path=path, config_path=config_path, meta_type=meta, file_encoding=file_encoding)
errors = create_new_client(
custom_template_path=custom_template_path,
config=config,
from . import generate

config = _process_config(
url=url,
path=path,
config_path=config_path,
meta_type=meta,
file_encoding=file_encoding,
overwrite=overwrite,
output_path=output_path,
)
handle_errors(errors, fail_on_warning)


@app.command()
def update(
url: Optional[str] = typer.Option(None, help="A URL to read the JSON from"),
path: Optional[Path] = typer.Option(None, help="A path to the JSON file"),
custom_template_path: Optional[Path] = typer.Option(None, **custom_template_path_options), # type: ignore
meta: MetaType = _meta_option,
file_encoding: str = typer.Option("utf-8", help="Encoding used when writing generated"),
config_path: Optional[Path] = CONFIG_OPTION,
fail_on_warning: bool = False,
) -> None:
"""Update an existing OpenAPI Client library

The update command performs the same operations as generate except it does not overwrite specific metadata for the
generated client such as the README.md, .gitignore, and pyproject.toml.
"""
from . import update_existing_client

config = _process_config(config_path=config_path, meta_type=meta, url=url, path=path, file_encoding=file_encoding)
errors = update_existing_client(
errors = generate(
custom_template_path=custom_template_path,
config=config,
)
Expand Down
Loading
Loading