openapi-cli-gen 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openapi_cli_gen/__init__.py +7 -0
- openapi_cli_gen/__main__.py +3 -0
- openapi_cli_gen/cli.py +70 -0
- openapi_cli_gen/codegen/__init__.py +0 -0
- openapi_cli_gen/codegen/generator.py +42 -0
- openapi_cli_gen/codegen/templates/__init__.py.jinja2 +1 -0
- openapi_cli_gen/codegen/templates/cli.py.jinja2 +15 -0
- openapi_cli_gen/codegen/templates/pyproject.toml.jinja2 +15 -0
- openapi_cli_gen/engine/__init__.py +0 -0
- openapi_cli_gen/engine/auth.py +52 -0
- openapi_cli_gen/engine/builder.py +127 -0
- openapi_cli_gen/engine/dispatch.py +62 -0
- openapi_cli_gen/engine/models.py +117 -0
- openapi_cli_gen/engine/registry.py +119 -0
- openapi_cli_gen/output/__init__.py +0 -0
- openapi_cli_gen/output/formatter.py +66 -0
- openapi_cli_gen/spec/__init__.py +0 -0
- openapi_cli_gen/spec/loader.py +34 -0
- openapi_cli_gen/spec/parser.py +104 -0
- openapi_cli_gen-0.0.1.dist-info/METADATA +259 -0
- openapi_cli_gen-0.0.1.dist-info/RECORD +24 -0
- openapi_cli_gen-0.0.1.dist-info/WHEEL +4 -0
- openapi_cli_gen-0.0.1.dist-info/entry_points.txt +2 -0
- openapi_cli_gen-0.0.1.dist-info/licenses/LICENSE +21 -0
openapi_cli_gen/cli.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from openapi_cli_gen.spec.loader import load_spec
|
|
6
|
+
from openapi_cli_gen.spec.parser import parse_spec, extract_security_schemes
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(
|
|
9
|
+
name="openapi-cli-gen",
|
|
10
|
+
help="Generate typed Python CLIs from OpenAPI specs.",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command()
|
|
15
|
+
def generate(
|
|
16
|
+
spec: str = typer.Option(..., help="Path to OpenAPI spec file"),
|
|
17
|
+
name: str = typer.Option(..., help="CLI/package name"),
|
|
18
|
+
output: str = typer.Option(None, help="Output directory (default: ./<name>)"),
|
|
19
|
+
):
|
|
20
|
+
"""Generate a CLI package from an OpenAPI spec."""
|
|
21
|
+
from openapi_cli_gen.codegen.generator import generate_package
|
|
22
|
+
|
|
23
|
+
output_dir = output or f"./{name}"
|
|
24
|
+
result = generate_package(spec=spec, name=name, output_dir=output_dir)
|
|
25
|
+
typer.echo(f"Generated CLI package at: {result}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command()
|
|
29
|
+
def run(
|
|
30
|
+
spec: str = typer.Option(..., help="Path to OpenAPI spec file"),
|
|
31
|
+
args: list[str] = typer.Argument(None, help="Command args: <group> <command> [--flags]"),
|
|
32
|
+
):
|
|
33
|
+
"""Run a CLI directly from an OpenAPI spec (no code generation)."""
|
|
34
|
+
from openapi_cli_gen import build_cli
|
|
35
|
+
|
|
36
|
+
cli = build_cli(spec=spec, name="cli")
|
|
37
|
+
cli(args or [])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def inspect(
|
|
42
|
+
spec: str = typer.Option(..., help="Path to OpenAPI spec file"),
|
|
43
|
+
):
|
|
44
|
+
"""Inspect an OpenAPI spec — show what would be generated."""
|
|
45
|
+
resolved = load_spec(spec)
|
|
46
|
+
endpoints = parse_spec(resolved)
|
|
47
|
+
schemes = extract_security_schemes(resolved)
|
|
48
|
+
|
|
49
|
+
groups: dict[str, list] = {}
|
|
50
|
+
for ep in endpoints:
|
|
51
|
+
groups.setdefault(ep.tag, []).append(ep)
|
|
52
|
+
|
|
53
|
+
title = resolved.get("info", {}).get("title", "Unknown")
|
|
54
|
+
version = resolved.get("info", {}).get("version", "?")
|
|
55
|
+
|
|
56
|
+
typer.echo(f"API: {title} v{version}")
|
|
57
|
+
typer.echo(f"Endpoints: {len(endpoints)}")
|
|
58
|
+
typer.echo(f"Groups: {len(groups)}")
|
|
59
|
+
typer.echo(f"Auth schemes: {len(schemes)}")
|
|
60
|
+
typer.echo()
|
|
61
|
+
|
|
62
|
+
for group_name, eps in sorted(groups.items()):
|
|
63
|
+
typer.echo(f" {group_name}:")
|
|
64
|
+
for ep in eps:
|
|
65
|
+
body = " [body]" if ep.body_schema else ""
|
|
66
|
+
typer.echo(f" {ep.method.upper():7} {ep.path:30} {ep.summary}{body}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main():
|
|
70
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from jinja2 import Environment, PackageLoader
|
|
7
|
+
|
|
8
|
+
import openapi_cli_gen
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_package(
|
|
12
|
+
spec: str,
|
|
13
|
+
name: str,
|
|
14
|
+
output_dir: str,
|
|
15
|
+
) -> Path:
|
|
16
|
+
"""Generate a CLI package from an OpenAPI spec."""
|
|
17
|
+
output = Path(output_dir)
|
|
18
|
+
pkg_dir = output / "src" / name
|
|
19
|
+
|
|
20
|
+
pkg_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
env = Environment(
|
|
23
|
+
loader=PackageLoader("openapi_cli_gen", "codegen/templates"),
|
|
24
|
+
keep_trailing_newline=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
context = {
|
|
28
|
+
"name": name,
|
|
29
|
+
"openapi_cli_gen_version": openapi_cli_gen.__version__,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for template_name, output_path in [
|
|
33
|
+
("cli.py.jinja2", pkg_dir / "cli.py"),
|
|
34
|
+
("__init__.py.jinja2", pkg_dir / "__init__.py"),
|
|
35
|
+
("pyproject.toml.jinja2", output / "pyproject.toml"),
|
|
36
|
+
]:
|
|
37
|
+
template = env.get_template(template_name)
|
|
38
|
+
output_path.write_text(template.render(**context))
|
|
39
|
+
|
|
40
|
+
shutil.copy2(spec, pkg_dir / "spec.yaml")
|
|
41
|
+
|
|
42
|
+
return output
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""{{ name }} CLI — generated by openapi-cli-gen."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "{{ name }}"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI for {{ name }} API"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"openapi-cli-gen>={{ openapi_cli_gen_version }}",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
{{ name }} = "{{ name }}.cli:main"
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from openapi_cli_gen.spec.parser import SecuritySchemeInfo
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthState:
|
|
9
|
+
"""Holds resolved auth credentials and produces HTTP headers."""
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self._headers: dict[str, str] = {}
|
|
13
|
+
self._token_override: str | None = None
|
|
14
|
+
self._scheme_type: str | None = None
|
|
15
|
+
self._header_name: str | None = None
|
|
16
|
+
|
|
17
|
+
def set_token(self, token: str) -> None:
|
|
18
|
+
self._token_override = token
|
|
19
|
+
|
|
20
|
+
def get_headers(self) -> dict[str, str]:
|
|
21
|
+
if self._token_override:
|
|
22
|
+
if self._scheme_type == "bearer":
|
|
23
|
+
return {"Authorization": f"Bearer {self._token_override}"}
|
|
24
|
+
elif self._scheme_type == "apiKey" and self._header_name:
|
|
25
|
+
return {self._header_name: self._token_override}
|
|
26
|
+
return dict(self._headers)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_auth_config(
|
|
30
|
+
cli_name: str,
|
|
31
|
+
schemes: list[SecuritySchemeInfo],
|
|
32
|
+
) -> AuthState:
|
|
33
|
+
"""Build auth state from security schemes + environment variables."""
|
|
34
|
+
prefix = cli_name.upper().replace("-", "_")
|
|
35
|
+
state = AuthState()
|
|
36
|
+
|
|
37
|
+
for scheme in schemes:
|
|
38
|
+
if scheme.type == "http" and scheme.scheme == "bearer":
|
|
39
|
+
state._scheme_type = "bearer"
|
|
40
|
+
token = os.environ.get(f"{prefix}_TOKEN")
|
|
41
|
+
if token:
|
|
42
|
+
state._headers = {"Authorization": f"Bearer {token}"}
|
|
43
|
+
break
|
|
44
|
+
elif scheme.type == "apiKey" and scheme.location == "header":
|
|
45
|
+
state._scheme_type = "apiKey"
|
|
46
|
+
state._header_name = scheme.header_name or "X-API-Key"
|
|
47
|
+
api_key = os.environ.get(f"{prefix}_API_KEY")
|
|
48
|
+
if api_key:
|
|
49
|
+
state._headers = {state._header_name: api_key}
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
return state
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from openapi_cli_gen.spec.loader import load_spec
|
|
11
|
+
from openapi_cli_gen.spec.parser import parse_spec, extract_security_schemes
|
|
12
|
+
from openapi_cli_gen.engine.registry import build_registry, CommandInfo
|
|
13
|
+
from openapi_cli_gen.engine.dispatch import dispatch
|
|
14
|
+
from openapi_cli_gen.engine.auth import build_auth_config
|
|
15
|
+
from openapi_cli_gen.output.formatter import format_output
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_cli(
|
|
19
|
+
spec: str | Path,
|
|
20
|
+
name: str = "cli",
|
|
21
|
+
base_url: str | None = None,
|
|
22
|
+
) -> Callable:
|
|
23
|
+
"""Build a CLI application from an OpenAPI spec.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
spec: Path to OpenAPI spec file.
|
|
27
|
+
name: CLI name (used for help text and env var prefix).
|
|
28
|
+
base_url: Override the API base URL. If None, uses first server from spec.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A callable that accepts a list of args (or uses sys.argv[1:]).
|
|
32
|
+
"""
|
|
33
|
+
spec_path = str(spec)
|
|
34
|
+
resolved = load_spec(spec_path)
|
|
35
|
+
endpoints = parse_spec(resolved)
|
|
36
|
+
security_schemes = extract_security_schemes(resolved)
|
|
37
|
+
registry = build_registry(endpoints)
|
|
38
|
+
auth_state = build_auth_config(name, security_schemes)
|
|
39
|
+
|
|
40
|
+
if base_url is None:
|
|
41
|
+
servers = resolved.get("servers", [])
|
|
42
|
+
base_url = servers[0]["url"] if servers else "http://localhost:8000"
|
|
43
|
+
|
|
44
|
+
# Attach cli_cmd to each command model
|
|
45
|
+
for group_cmds in registry.values():
|
|
46
|
+
for cmd_info in group_cmds.values():
|
|
47
|
+
_attach_cli_cmd(cmd_info, base_url, auth_state)
|
|
48
|
+
|
|
49
|
+
def app(args: list[str] | None = None):
|
|
50
|
+
if args is None:
|
|
51
|
+
args = sys.argv[1:]
|
|
52
|
+
dispatch(registry, args, name=name)
|
|
53
|
+
|
|
54
|
+
return app
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _attach_cli_cmd(cmd_info: CommandInfo, base_url: str, auth_state) -> None:
|
|
58
|
+
"""Attach a cli_cmd method to the command model that makes the HTTP call."""
|
|
59
|
+
ep = cmd_info.endpoint
|
|
60
|
+
|
|
61
|
+
def cli_cmd(self):
|
|
62
|
+
data = self.model_dump(exclude_none=True)
|
|
63
|
+
|
|
64
|
+
# Separate path params from body/query
|
|
65
|
+
path_names = {p.name for p in ep.path_params}
|
|
66
|
+
query_names = {p.name for p in ep.query_params}
|
|
67
|
+
|
|
68
|
+
path_params = {k: v for k, v in data.items() if k in path_names}
|
|
69
|
+
query_params = {k: v for k, v in data.items() if k in query_names}
|
|
70
|
+
body = {k: v for k, v in data.items() if k not in path_names and k not in query_names}
|
|
71
|
+
|
|
72
|
+
# Build URL with path params
|
|
73
|
+
path = ep.path
|
|
74
|
+
for k, v in path_params.items():
|
|
75
|
+
path = path.replace(f"{{{k}}}", str(v))
|
|
76
|
+
url = f"{base_url}{path}"
|
|
77
|
+
|
|
78
|
+
headers = auth_state.get_headers()
|
|
79
|
+
|
|
80
|
+
with httpx.Client() as client:
|
|
81
|
+
if ep.method in ("post", "put", "patch"):
|
|
82
|
+
resp = client.request(ep.method.upper(), url, json=body or None, params=query_params, headers=headers)
|
|
83
|
+
else:
|
|
84
|
+
resp = client.request(ep.method.upper(), url, params=query_params, headers=headers)
|
|
85
|
+
|
|
86
|
+
if resp.status_code >= 400:
|
|
87
|
+
print(f"Error: {resp.status_code}")
|
|
88
|
+
try:
|
|
89
|
+
print(format_output(resp.json(), "json"))
|
|
90
|
+
except Exception:
|
|
91
|
+
print(resp.text)
|
|
92
|
+
raise SystemExit(1)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
result = resp.json()
|
|
96
|
+
except Exception:
|
|
97
|
+
result = resp.text
|
|
98
|
+
|
|
99
|
+
output = format_output(result, "json")
|
|
100
|
+
if output is not None:
|
|
101
|
+
print(output)
|
|
102
|
+
|
|
103
|
+
cmd_info.model.cli_cmd = cli_cmd
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_command_group(
|
|
107
|
+
spec: str | Path,
|
|
108
|
+
name: str = "api",
|
|
109
|
+
base_url: str | None = None,
|
|
110
|
+
) -> dict[str, dict[str, CommandInfo]]:
|
|
111
|
+
"""Build command group from spec. Returns the registry for programmatic use."""
|
|
112
|
+
spec_path = str(spec)
|
|
113
|
+
resolved = load_spec(spec_path)
|
|
114
|
+
endpoints = parse_spec(resolved)
|
|
115
|
+
security_schemes = extract_security_schemes(resolved)
|
|
116
|
+
registry = build_registry(endpoints)
|
|
117
|
+
auth_state = build_auth_config(name, security_schemes)
|
|
118
|
+
|
|
119
|
+
if base_url is None:
|
|
120
|
+
servers = resolved.get("servers", [])
|
|
121
|
+
base_url = servers[0]["url"] if servers else "http://localhost:8000"
|
|
122
|
+
|
|
123
|
+
for group_cmds in registry.values():
|
|
124
|
+
for cmd_info in group_cmds.values():
|
|
125
|
+
_attach_cli_cmd(cmd_info, base_url, auth_state)
|
|
126
|
+
|
|
127
|
+
return registry
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from pydantic_settings import CliApp
|
|
6
|
+
|
|
7
|
+
from openapi_cli_gen.engine.registry import CommandInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def dispatch(
|
|
11
|
+
registry: dict[str, dict[str, CommandInfo]],
|
|
12
|
+
args: list[str],
|
|
13
|
+
name: str = "cli",
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Dispatch CLI args to the right command via manual group+command routing."""
|
|
16
|
+
if not args or args[0] in ("-h", "--help"):
|
|
17
|
+
_print_root_help(registry, name)
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
group = args[0]
|
|
21
|
+
if group not in registry:
|
|
22
|
+
print(f"Error: unknown group '{group}'. Available: {', '.join(sorted(registry.keys()))}")
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
remaining = args[1:]
|
|
26
|
+
if not remaining or remaining[0] in ("-h", "--help"):
|
|
27
|
+
_print_group_help(registry[group], group, name)
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
command = remaining[0]
|
|
31
|
+
if command not in registry[group]:
|
|
32
|
+
print(f"Error: unknown command '{group} {command}'. Available: {', '.join(sorted(registry[group].keys()))}")
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
cmd_info = registry[group][command]
|
|
36
|
+
flag_args = remaining[1:]
|
|
37
|
+
|
|
38
|
+
CliApp.run(cmd_info.model, cli_args=flag_args)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _print_root_help(
|
|
42
|
+
registry: dict[str, dict[str, CommandInfo]],
|
|
43
|
+
name: str,
|
|
44
|
+
) -> None:
|
|
45
|
+
print(f"Usage: {name} <group> <command> [options]\n")
|
|
46
|
+
print("Groups:")
|
|
47
|
+
for group, commands in sorted(registry.items()):
|
|
48
|
+
cmds = ", ".join(sorted(commands.keys()))
|
|
49
|
+
print(f" {group:20} {cmds}")
|
|
50
|
+
print(f"\nUse '{name} <group> --help' for group help")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _print_group_help(
|
|
54
|
+
commands: dict[str, CommandInfo],
|
|
55
|
+
group: str,
|
|
56
|
+
name: str,
|
|
57
|
+
) -> None:
|
|
58
|
+
print(f"Usage: {name} {group} <command> [options]\n")
|
|
59
|
+
print("Commands:")
|
|
60
|
+
for cmd_name, cmd_info in sorted(commands.items()):
|
|
61
|
+
doc = cmd_info.endpoint.summary or cmd_info.endpoint.operation_id
|
|
62
|
+
print(f" {cmd_name:20} {doc}")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, create_model
|
|
7
|
+
from pydantic.fields import FieldInfo
|
|
8
|
+
|
|
9
|
+
TYPE_MAP: dict[str, type] = {
|
|
10
|
+
"string": str,
|
|
11
|
+
"integer": int,
|
|
12
|
+
"number": float,
|
|
13
|
+
"boolean": bool,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def schema_to_model(
|
|
18
|
+
name: str,
|
|
19
|
+
schema: dict,
|
|
20
|
+
doc: str = "",
|
|
21
|
+
_model_cache: dict[str, type[BaseModel]] | None = None,
|
|
22
|
+
) -> type[BaseModel]:
|
|
23
|
+
"""Convert a JSON Schema object to a dynamic Pydantic model.
|
|
24
|
+
|
|
25
|
+
Handles: primitives, nested objects, arrays, enums, dicts, nullable.
|
|
26
|
+
Nested objects become nested BaseModel subclasses (works with CliApp dot-notation).
|
|
27
|
+
"""
|
|
28
|
+
if _model_cache is None:
|
|
29
|
+
_model_cache = {}
|
|
30
|
+
|
|
31
|
+
if name in _model_cache:
|
|
32
|
+
return _model_cache[name]
|
|
33
|
+
|
|
34
|
+
fields: dict[str, Any] = {}
|
|
35
|
+
required_fields = set(schema.get("required", []))
|
|
36
|
+
properties = schema.get("properties", {})
|
|
37
|
+
|
|
38
|
+
for field_name, prop in properties.items():
|
|
39
|
+
py_type, field_info = _property_to_field(
|
|
40
|
+
field_name, prop, field_name in required_fields, name, _model_cache
|
|
41
|
+
)
|
|
42
|
+
fields[field_name] = (py_type, field_info)
|
|
43
|
+
|
|
44
|
+
model = create_model(name, __doc__=doc or name, **fields)
|
|
45
|
+
_model_cache[name] = model
|
|
46
|
+
return model
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _property_to_field(
|
|
50
|
+
field_name: str,
|
|
51
|
+
prop: dict,
|
|
52
|
+
is_required: bool,
|
|
53
|
+
parent_name: str,
|
|
54
|
+
model_cache: dict[str, type[BaseModel]],
|
|
55
|
+
) -> tuple[type, FieldInfo]:
|
|
56
|
+
"""Convert a single JSON Schema property to a (type, FieldInfo) tuple."""
|
|
57
|
+
prop_type = prop.get("type", "string")
|
|
58
|
+
|
|
59
|
+
# Handle nullable (3.1 style: type as list)
|
|
60
|
+
nullable = False
|
|
61
|
+
if isinstance(prop_type, list):
|
|
62
|
+
non_null = [t for t in prop_type if t != "null"]
|
|
63
|
+
nullable = len(non_null) < len(prop_type)
|
|
64
|
+
prop_type = non_null[0] if non_null else "string"
|
|
65
|
+
|
|
66
|
+
# Handle nested object with properties → nested BaseModel
|
|
67
|
+
if prop_type == "object" and "properties" in prop:
|
|
68
|
+
nested_name = f"{parent_name}_{field_name.title()}"
|
|
69
|
+
nested_model = schema_to_model(nested_name, prop, _model_cache=model_cache)
|
|
70
|
+
py_type = nested_model | None
|
|
71
|
+
return py_type, FieldInfo(default=None)
|
|
72
|
+
|
|
73
|
+
# Handle dict (additionalProperties without properties)
|
|
74
|
+
if prop_type == "object" and "additionalProperties" in prop:
|
|
75
|
+
value_type = TYPE_MAP.get(
|
|
76
|
+
prop.get("additionalProperties", {}).get("type", "string"), str
|
|
77
|
+
)
|
|
78
|
+
py_type = dict[str, value_type] | None
|
|
79
|
+
return py_type, FieldInfo(default=None)
|
|
80
|
+
|
|
81
|
+
# Handle array
|
|
82
|
+
if prop_type == "array":
|
|
83
|
+
items = prop.get("items", {})
|
|
84
|
+
item_type_str = items.get("type", "string")
|
|
85
|
+
if item_type_str == "object" and "properties" in items:
|
|
86
|
+
item_model = schema_to_model(
|
|
87
|
+
f"{parent_name}_{field_name.title()}Item", items, _model_cache=model_cache
|
|
88
|
+
)
|
|
89
|
+
py_type = list[item_model]
|
|
90
|
+
else:
|
|
91
|
+
item_type = TYPE_MAP.get(item_type_str, str)
|
|
92
|
+
py_type = list[item_type]
|
|
93
|
+
|
|
94
|
+
if not is_required:
|
|
95
|
+
py_type = py_type | None
|
|
96
|
+
return py_type, FieldInfo(default=None)
|
|
97
|
+
return py_type, FieldInfo()
|
|
98
|
+
|
|
99
|
+
# Handle enum
|
|
100
|
+
enum_values = prop.get("enum")
|
|
101
|
+
if enum_values:
|
|
102
|
+
enum_cls = Enum(f"{parent_name}_{field_name.title()}", {v: v for v in enum_values}, type=str)
|
|
103
|
+
py_type = enum_cls
|
|
104
|
+
else:
|
|
105
|
+
py_type = TYPE_MAP.get(prop_type, str)
|
|
106
|
+
|
|
107
|
+
# Handle nullable
|
|
108
|
+
if nullable or not is_required:
|
|
109
|
+
py_type = py_type | None
|
|
110
|
+
|
|
111
|
+
default = prop.get("default")
|
|
112
|
+
if default is not None:
|
|
113
|
+
return py_type, FieldInfo(default=default)
|
|
114
|
+
elif is_required and not nullable:
|
|
115
|
+
return py_type, FieldInfo()
|
|
116
|
+
else:
|
|
117
|
+
return py_type, FieldInfo(default=None)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, create_model
|
|
7
|
+
from pydantic.fields import FieldInfo
|
|
8
|
+
|
|
9
|
+
from openapi_cli_gen.engine.models import schema_to_model, TYPE_MAP
|
|
10
|
+
from openapi_cli_gen.spec.parser import EndpointInfo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CommandInfo:
|
|
15
|
+
model: type[BaseModel]
|
|
16
|
+
endpoint: EndpointInfo
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_registry(
|
|
20
|
+
endpoints: list[EndpointInfo],
|
|
21
|
+
) -> dict[str, dict[str, CommandInfo]]:
|
|
22
|
+
"""Build a command registry from parsed endpoints.
|
|
23
|
+
|
|
24
|
+
Returns: {group_name: {command_name: CommandInfo}}
|
|
25
|
+
"""
|
|
26
|
+
registry: dict[str, dict[str, CommandInfo]] = {}
|
|
27
|
+
model_cache: dict[str, type[BaseModel]] = {}
|
|
28
|
+
|
|
29
|
+
for ep in endpoints:
|
|
30
|
+
group = ep.tag
|
|
31
|
+
cmd_name = _derive_command_name(ep)
|
|
32
|
+
model = _build_command_model(ep, model_cache)
|
|
33
|
+
|
|
34
|
+
registry.setdefault(group, {})[cmd_name] = CommandInfo(
|
|
35
|
+
model=model,
|
|
36
|
+
endpoint=ep,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return registry
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _derive_command_name(ep: EndpointInfo) -> str:
|
|
43
|
+
"""Derive a CLI command name from an endpoint.
|
|
44
|
+
|
|
45
|
+
Strategy: strip the tag suffix (singular or plural) from operationId, kebab-case the rest.
|
|
46
|
+
Examples: list_users → list, create_user → create, get_user → get
|
|
47
|
+
"""
|
|
48
|
+
op_id = ep.operation_id
|
|
49
|
+
tag = ep.tag
|
|
50
|
+
|
|
51
|
+
# Build variants of the tag to try stripping from the end of op_id
|
|
52
|
+
# e.g. tag="users" → try "_users", "_user"
|
|
53
|
+
# e.g. tag="companies" → try "_companies", "_company", "_companie"
|
|
54
|
+
singular = tag.rstrip("s") if tag.endswith("s") else tag
|
|
55
|
+
suffixes_to_try = [f"_{tag}", f"_{singular}", f"_{tag}s"]
|
|
56
|
+
|
|
57
|
+
for suffix in suffixes_to_try:
|
|
58
|
+
if op_id.endswith(suffix):
|
|
59
|
+
remainder = op_id[: -len(suffix)]
|
|
60
|
+
if remainder:
|
|
61
|
+
return _to_kebab(remainder)
|
|
62
|
+
|
|
63
|
+
# Try stripping tag as a prefix (e.g. users_list → list)
|
|
64
|
+
for prefix in [f"{tag}_", f"{singular}_"]:
|
|
65
|
+
if op_id.startswith(prefix):
|
|
66
|
+
remainder = op_id[len(prefix):]
|
|
67
|
+
if remainder:
|
|
68
|
+
return _to_kebab(remainder)
|
|
69
|
+
|
|
70
|
+
# Try extracting just the leading verb if nothing else matched
|
|
71
|
+
for verb in ("list", "get", "create", "update", "delete", "patch", "send", "trigger"):
|
|
72
|
+
if op_id.startswith(f"{verb}_") or op_id == verb:
|
|
73
|
+
return verb
|
|
74
|
+
|
|
75
|
+
return _to_kebab(op_id)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _to_kebab(name: str) -> str:
|
|
79
|
+
"""Convert snake_case or camelCase to kebab-case."""
|
|
80
|
+
name = name.replace("_", "-")
|
|
81
|
+
name = re.sub(r"([a-z])([A-Z])", r"\1-\2", name).lower()
|
|
82
|
+
return name
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _build_command_model(
|
|
86
|
+
ep: EndpointInfo,
|
|
87
|
+
model_cache: dict[str, type[BaseModel]],
|
|
88
|
+
) -> type[BaseModel]:
|
|
89
|
+
"""Build a dynamic Pydantic model combining params + body fields."""
|
|
90
|
+
from typing import Any
|
|
91
|
+
|
|
92
|
+
fields: dict[str, Any] = {}
|
|
93
|
+
|
|
94
|
+
# Path params → required fields
|
|
95
|
+
for p in ep.path_params:
|
|
96
|
+
py_type = TYPE_MAP.get(p.type, str)
|
|
97
|
+
fields[p.name] = (py_type, FieldInfo(description=p.name))
|
|
98
|
+
|
|
99
|
+
# Query params → optional fields with defaults
|
|
100
|
+
for p in ep.query_params:
|
|
101
|
+
py_type = TYPE_MAP.get(p.type, str)
|
|
102
|
+
if not p.required:
|
|
103
|
+
py_type = py_type | None
|
|
104
|
+
default = p.default if p.default is not None else (None if not p.required else ...)
|
|
105
|
+
fields[p.name] = (py_type, FieldInfo(default=default))
|
|
106
|
+
|
|
107
|
+
# Body schema → merge fields from schema_to_model
|
|
108
|
+
if ep.body_schema:
|
|
109
|
+
body_model = schema_to_model(
|
|
110
|
+
f"{ep.tag.title()}{ep.operation_id.title().replace('_', '')}Body",
|
|
111
|
+
ep.body_schema,
|
|
112
|
+
doc=ep.summary,
|
|
113
|
+
_model_cache=model_cache,
|
|
114
|
+
)
|
|
115
|
+
for fname, finfo in body_model.model_fields.items():
|
|
116
|
+
fields[fname] = (finfo.annotation, finfo)
|
|
117
|
+
|
|
118
|
+
model_name = f"Cmd_{ep.operation_id}"
|
|
119
|
+
return create_model(model_name, __doc__=ep.summary or ep.operation_id, **fields)
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_output(
|
|
11
|
+
data: dict | list,
|
|
12
|
+
fmt: str = "json",
|
|
13
|
+
print_output: bool = False,
|
|
14
|
+
) -> str | None:
|
|
15
|
+
"""Format API response data for CLI output."""
|
|
16
|
+
if fmt == "json":
|
|
17
|
+
result = json.dumps(data, indent=2, default=str)
|
|
18
|
+
elif fmt == "yaml":
|
|
19
|
+
result = yaml.dump(data, default_flow_style=False, sort_keys=False)
|
|
20
|
+
elif fmt == "raw":
|
|
21
|
+
result = str(data)
|
|
22
|
+
elif fmt == "table":
|
|
23
|
+
_print_table(data)
|
|
24
|
+
return None
|
|
25
|
+
else:
|
|
26
|
+
result = json.dumps(data, indent=2, default=str)
|
|
27
|
+
|
|
28
|
+
if print_output:
|
|
29
|
+
print(result)
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _print_table(data: dict | list) -> None:
|
|
34
|
+
"""Print data as a rich table."""
|
|
35
|
+
console = Console()
|
|
36
|
+
rows = None
|
|
37
|
+
if isinstance(data, list):
|
|
38
|
+
rows = data
|
|
39
|
+
elif isinstance(data, dict):
|
|
40
|
+
for key in ("items", "results", "data"):
|
|
41
|
+
if key in data and isinstance(data[key], list):
|
|
42
|
+
rows = data[key]
|
|
43
|
+
for k, v in data.items():
|
|
44
|
+
if k != key:
|
|
45
|
+
console.print(f"[dim]{k}:[/dim] {v}")
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
if rows and len(rows) > 0:
|
|
49
|
+
table = Table(show_header=True, header_style="bold")
|
|
50
|
+
keys = list(rows[0].keys())
|
|
51
|
+
for key in keys:
|
|
52
|
+
table.add_column(key)
|
|
53
|
+
for row in rows:
|
|
54
|
+
table.add_row(*[str(row.get(k, "")) for k in keys])
|
|
55
|
+
console.print(table)
|
|
56
|
+
elif isinstance(data, dict):
|
|
57
|
+
table = Table(show_header=True, header_style="bold")
|
|
58
|
+
table.add_column("Field")
|
|
59
|
+
table.add_column("Value")
|
|
60
|
+
for key, value in data.items():
|
|
61
|
+
if isinstance(value, (dict, list)):
|
|
62
|
+
value = json.dumps(value, default=str)
|
|
63
|
+
table.add_row(str(key), str(value))
|
|
64
|
+
console.print(table)
|
|
65
|
+
else:
|
|
66
|
+
console.print(str(data))
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import jsonref
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_spec(spec_path: str) -> dict:
|
|
11
|
+
"""Load an OpenAPI spec from a file path, resolve all $ref references.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
spec_path: Path to YAML or JSON OpenAPI spec file.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Fully resolved spec as a dict (all $ref inlined).
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
FileNotFoundError: If spec file does not exist.
|
|
21
|
+
"""
|
|
22
|
+
path = Path(spec_path)
|
|
23
|
+
if not path.exists():
|
|
24
|
+
raise FileNotFoundError(f"Spec file not found: {spec_path}")
|
|
25
|
+
|
|
26
|
+
text = path.read_text()
|
|
27
|
+
|
|
28
|
+
if path.suffix in (".json",):
|
|
29
|
+
raw = json.loads(text)
|
|
30
|
+
else:
|
|
31
|
+
raw = yaml.safe_load(text)
|
|
32
|
+
|
|
33
|
+
base_uri = path.absolute().as_uri()
|
|
34
|
+
return jsonref.replace_refs(raw, base_uri=base_uri)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ParamInfo:
|
|
8
|
+
name: str
|
|
9
|
+
type: str # "string", "integer", "number", "boolean"
|
|
10
|
+
required: bool = False
|
|
11
|
+
default: object = None
|
|
12
|
+
enum: list[str] | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class SecuritySchemeInfo:
|
|
17
|
+
name: str
|
|
18
|
+
type: str # "http", "apiKey", "oauth2", "openIdConnect"
|
|
19
|
+
scheme: str | None = None # "bearer", "basic" (for http type)
|
|
20
|
+
header_name: str | None = None # for apiKey type
|
|
21
|
+
location: str | None = None # "header", "query", "cookie" (for apiKey)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class EndpointInfo:
|
|
26
|
+
operation_id: str
|
|
27
|
+
tag: str
|
|
28
|
+
method: str # "get", "post", "put", "patch", "delete"
|
|
29
|
+
path: str
|
|
30
|
+
summary: str = ""
|
|
31
|
+
path_params: list[ParamInfo] = field(default_factory=list)
|
|
32
|
+
query_params: list[ParamInfo] = field(default_factory=list)
|
|
33
|
+
body_schema: dict | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
HTTP_METHODS = ("get", "post", "put", "patch", "delete")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_spec(resolved_spec: dict) -> list[EndpointInfo]:
|
|
40
|
+
"""Parse a resolved OpenAPI spec into a list of EndpointInfo."""
|
|
41
|
+
endpoints = []
|
|
42
|
+
|
|
43
|
+
for path, path_item in resolved_spec.get("paths", {}).items():
|
|
44
|
+
for method in HTTP_METHODS:
|
|
45
|
+
operation = path_item.get(method)
|
|
46
|
+
if not operation:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
tag = (operation.get("tags") or ["default"])[0]
|
|
50
|
+
op_id = operation.get("operationId", f"{method}_{path.strip('/').replace('/', '_')}")
|
|
51
|
+
|
|
52
|
+
path_params = []
|
|
53
|
+
query_params = []
|
|
54
|
+
for p in operation.get("parameters", []):
|
|
55
|
+
schema = p.get("schema", {})
|
|
56
|
+
param = ParamInfo(
|
|
57
|
+
name=p["name"],
|
|
58
|
+
type=schema.get("type", "string"),
|
|
59
|
+
required=p.get("required", False),
|
|
60
|
+
default=schema.get("default"),
|
|
61
|
+
enum=schema.get("enum"),
|
|
62
|
+
)
|
|
63
|
+
if p["in"] == "path":
|
|
64
|
+
path_params.append(param)
|
|
65
|
+
elif p["in"] == "query":
|
|
66
|
+
query_params.append(param)
|
|
67
|
+
|
|
68
|
+
body_schema = None
|
|
69
|
+
rb = operation.get("requestBody")
|
|
70
|
+
if rb:
|
|
71
|
+
content = rb.get("content", {})
|
|
72
|
+
json_content = content.get("application/json", {})
|
|
73
|
+
body_schema = json_content.get("schema")
|
|
74
|
+
|
|
75
|
+
endpoints.append(EndpointInfo(
|
|
76
|
+
operation_id=op_id,
|
|
77
|
+
tag=tag,
|
|
78
|
+
method=method,
|
|
79
|
+
path=path,
|
|
80
|
+
summary=operation.get("summary", ""),
|
|
81
|
+
path_params=path_params,
|
|
82
|
+
query_params=query_params,
|
|
83
|
+
body_schema=body_schema,
|
|
84
|
+
))
|
|
85
|
+
|
|
86
|
+
return endpoints
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def extract_security_schemes(resolved_spec: dict) -> list[SecuritySchemeInfo]:
|
|
90
|
+
"""Extract security scheme info from spec."""
|
|
91
|
+
schemes = []
|
|
92
|
+
components = resolved_spec.get("components", {})
|
|
93
|
+
security_schemes = components.get("securitySchemes", {})
|
|
94
|
+
|
|
95
|
+
for name, scheme in security_schemes.items():
|
|
96
|
+
schemes.append(SecuritySchemeInfo(
|
|
97
|
+
name=name,
|
|
98
|
+
type=scheme.get("type", ""),
|
|
99
|
+
scheme=scheme.get("scheme"),
|
|
100
|
+
header_name=scheme.get("name"),
|
|
101
|
+
location=scheme.get("in"),
|
|
102
|
+
))
|
|
103
|
+
|
|
104
|
+
return schemes
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openapi-cli-gen
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Generate typed Python CLIs from OpenAPI specs with Pydantic model flattening into CLI flags
|
|
5
|
+
Project-URL: Homepage, https://github.com/shivaam/openapi-cli-gen
|
|
6
|
+
Project-URL: Repository, https://github.com/shivaam/openapi-cli-gen
|
|
7
|
+
Project-URL: Issues, https://github.com/shivaam/openapi-cli-gen/issues
|
|
8
|
+
Author: shivaam
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,code-generator,fastapi,openapi,pydantic,typer
|
|
12
|
+
Classifier: Development Status :: 1 - Planning
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: jinja2>=3.1
|
|
25
|
+
Requires-Dist: jsonref>=1.1
|
|
26
|
+
Requires-Dist: openapi-pydantic>=0.5
|
|
27
|
+
Requires-Dist: pydantic-settings>=2.13
|
|
28
|
+
Requires-Dist: pyyaml>=6.0
|
|
29
|
+
Requires-Dist: rich>=13.0
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: datamodel-code-generator>=0.56; extra == 'dev'
|
|
32
|
+
Requires-Dist: fastapi>=0.115; extra == 'dev'
|
|
33
|
+
Requires-Dist: jinja2>=3.1; extra == 'dev'
|
|
34
|
+
Requires-Dist: pydanclick>=0.5; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
37
|
+
Requires-Dist: typer>=0.12; extra == 'dev'
|
|
38
|
+
Requires-Dist: uvicorn>=0.30; extra == 'dev'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# openapi-cli-gen
|
|
42
|
+
|
|
43
|
+
**Generate a full CLI from any OpenAPI spec in seconds. Nested request bodies become flat `--flags` automatically.**
|
|
44
|
+
|
|
45
|
+
[](https://pypi.org/project/openapi-cli-gen/)
|
|
46
|
+
[](https://pypi.org/project/openapi-cli-gen/)
|
|
47
|
+
[](https://opensource.org/licenses/MIT)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Instead of this:
|
|
51
|
+
curl -X POST /api/users -d '{"name": "John", "address": {"city": "NYC", "state": "NY"}}'
|
|
52
|
+
|
|
53
|
+
# You get this:
|
|
54
|
+
mycli users create --name John --address.city NYC --address.state NY
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
No tool existed to take an OpenAPI spec and produce a typed Python CLI where nested request bodies are flattened into individual `--flag` arguments. Until now.
|
|
58
|
+
|
|
59
|
+
## Why openapi-cli-gen?
|
|
60
|
+
|
|
61
|
+
- **Zero boilerplate** -- point at a spec, get a working CLI
|
|
62
|
+
- **Nested model flattening** -- `--address.city NYC` not `--data '{"address":{"city":"NYC"}}'`
|
|
63
|
+
- **Works at any depth** -- 1, 2, 3+ levels of nesting with automatic dot-notation
|
|
64
|
+
- **Arrays, dicts, enums** -- all handled (`--tags dev --tags lead`, `--env KEY=val`, `--role {admin,user}`)
|
|
65
|
+
- **Auth from your spec** -- reads `securitySchemes`, wires up `--token` + env vars automatically
|
|
66
|
+
- **Two modes** -- generate a pip-installable package OR run instantly without code generation
|
|
67
|
+
- **Pluggable** -- use standalone or plug API commands into your existing CLI
|
|
68
|
+
|
|
69
|
+
## Quick Start
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install openapi-cli-gen
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Generate a CLI Package
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
openapi-cli-gen generate --spec https://api.example.com/openapi.json --name mycli
|
|
79
|
+
cd mycli && pip install -e .
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Now your users can:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
mycli users list
|
|
86
|
+
mycli users create --name John --email john@example.com --address.city NYC
|
|
87
|
+
mycli jobs create --retry.backoff.strategy exponential --retry.backoff.initial-delay-ms 2000
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Run Instantly (No Code Generation)
|
|
91
|
+
|
|
92
|
+
Don't want to generate files? Point directly at any spec:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
openapi-cli-gen run --spec api.yaml users list --limit 10
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Inspect a Spec
|
|
99
|
+
|
|
100
|
+
See what commands would be generated before committing:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
$ openapi-cli-gen inspect --spec api.yaml
|
|
104
|
+
|
|
105
|
+
API: My API v1.0
|
|
106
|
+
Endpoints: 14
|
|
107
|
+
Groups: 6
|
|
108
|
+
Auth schemes: 2
|
|
109
|
+
|
|
110
|
+
users:
|
|
111
|
+
GET /users List all users
|
|
112
|
+
POST /users Create a new user [body]
|
|
113
|
+
GET /users/{user_id} Get a user by ID
|
|
114
|
+
orders:
|
|
115
|
+
POST /orders Create an order [body]
|
|
116
|
+
...
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## The Core Feature: Nested Model Flattening
|
|
120
|
+
|
|
121
|
+
This is what makes openapi-cli-gen different from every other tool. Your API has nested request bodies -- we flatten them into ergonomic CLI flags:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
# Depth 1: address nested inside user
|
|
125
|
+
mycli users create --name John --address.city NYC --address.state NY
|
|
126
|
+
|
|
127
|
+
# Depth 2: CEO nested inside company
|
|
128
|
+
mycli companies create --name Acme --ceo.name Bob --ceo.email bob@acme.com
|
|
129
|
+
|
|
130
|
+
# Depth 3: backoff nested inside retry nested inside job config
|
|
131
|
+
mycli jobs create --name etl --retry.backoff.strategy exponential --retry.backoff.initial-delay-ms 2000
|
|
132
|
+
|
|
133
|
+
# JSON fallback for anything complex
|
|
134
|
+
mycli users create --address '{"street": "123 Main", "city": "NYC"}'
|
|
135
|
+
|
|
136
|
+
# Mix both -- dot-notation overrides JSON
|
|
137
|
+
mycli users create --address '{"city": "NYC"}' --address.city SF # city=SF wins
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Arrays
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# Repeated flags for primitives
|
|
144
|
+
mycli users create --tags admin --tags reviewer
|
|
145
|
+
# Or comma-separated
|
|
146
|
+
mycli users create --tags admin,reviewer
|
|
147
|
+
# Or JSON
|
|
148
|
+
mycli users create --tags '["admin", "reviewer"]'
|
|
149
|
+
|
|
150
|
+
# JSON for arrays of objects
|
|
151
|
+
mycli orders create --items '[{"product_id": "abc", "quantity": 2}]'
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Dicts
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
# Key=value syntax
|
|
158
|
+
mycli jobs create --environment JAVA_HOME=/usr/lib/jvm --environment PATH=/usr/bin
|
|
159
|
+
# Or JSON
|
|
160
|
+
mycli jobs create --environment '{"JAVA_HOME": "/usr/lib/jvm"}'
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Enums
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
mycli users create --role admin # choices shown in --help: {admin, user, viewer}
|
|
167
|
+
mycli users create --role superadmin # ValidationError: Input should be 'admin', 'user' or 'viewer'
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## As a Library
|
|
171
|
+
|
|
172
|
+
### Build a full CLI from a spec
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from openapi_cli_gen import build_cli
|
|
176
|
+
|
|
177
|
+
app = build_cli(spec="openapi.yaml", name="mycli")
|
|
178
|
+
app()
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Plug API commands into your existing CLI
|
|
182
|
+
|
|
183
|
+
Already have a CLI with custom commands? Add auto-generated API commands alongside them:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from openapi_cli_gen import build_command_group
|
|
187
|
+
|
|
188
|
+
# Returns {group: {command: CommandInfo}}
|
|
189
|
+
registry = build_command_group(spec="openapi.yaml", name="mycli")
|
|
190
|
+
# Integrate with your existing argparse-based CLI
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Authentication
|
|
194
|
+
|
|
195
|
+
Auth auto-configures from your spec's `securitySchemes`:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
# Via environment variable (recommended for CI/CD)
|
|
199
|
+
export MYCLI_TOKEN=sk-xxx
|
|
200
|
+
mycli users list
|
|
201
|
+
|
|
202
|
+
# Via flag (overrides env var)
|
|
203
|
+
mycli users list --token sk-xxx
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
| Spec scheme | CLI flag | Env var |
|
|
207
|
+
|---|---|---|
|
|
208
|
+
| Bearer token | `--token` | `{NAME}_TOKEN` |
|
|
209
|
+
| API key | `--api-key` | `{NAME}_API_KEY` |
|
|
210
|
+
| Basic auth | `--username`, `--password` | `{NAME}_USERNAME`, `{NAME}_PASSWORD` |
|
|
211
|
+
|
|
212
|
+
## How It Works
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
Your OpenAPI spec (YAML/JSON)
|
|
216
|
+
|
|
|
217
|
+
v
|
|
218
|
+
1. Load & resolve all $ref references (jsonref)
|
|
219
|
+
2. Parse into typed models (openapi-pydantic)
|
|
220
|
+
3. Group endpoints by tag -> command groups
|
|
221
|
+
4. Flatten request body schemas into CLI flags (pydantic-settings)
|
|
222
|
+
5. Build CLI with dispatch: mycli <group> <command> --flags
|
|
223
|
+
|
|
|
224
|
+
v
|
|
225
|
+
Working CLI in seconds
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Compared to Alternatives
|
|
229
|
+
|
|
230
|
+
| Feature | openapi-cli-gen | specli | restish | Stainless |
|
|
231
|
+
|---|---|---|---|---|
|
|
232
|
+
| Language | Python | TypeScript | Go | Go |
|
|
233
|
+
| Generates distributable code | Yes | No | No | Yes |
|
|
234
|
+
| Runtime mode (no codegen) | Yes | Yes | Yes | No |
|
|
235
|
+
| Nested model flattening | All depths | Scalars only | No | 2 levels |
|
|
236
|
+
| Typed Pydantic models | Yes | No | No | N/A |
|
|
237
|
+
| Auth from spec | Yes | Partial | Manual | Yes |
|
|
238
|
+
| Pluggable into existing CLI | Yes | No | No | No |
|
|
239
|
+
| Open source | Yes | Yes | Yes | No |
|
|
240
|
+
|
|
241
|
+
## Supported
|
|
242
|
+
|
|
243
|
+
- OpenAPI 3.0 and 3.1
|
|
244
|
+
- All HTTP methods (GET, POST, PUT, PATCH, DELETE)
|
|
245
|
+
- Local, external, and circular `$ref` resolution
|
|
246
|
+
- Path parameters, query parameters, request bodies
|
|
247
|
+
- Nested objects, arrays, dicts, enums, nullable fields
|
|
248
|
+
- Bearer token and API key authentication
|
|
249
|
+
- JSON, YAML, table (rich), and raw output formats
|
|
250
|
+
|
|
251
|
+
## Status
|
|
252
|
+
|
|
253
|
+
Early release (v0.0.1). Core features work. Roadmap includes Typer output target (rich `--help`, shell completion), auto-pagination, OAuth2, and more.
|
|
254
|
+
|
|
255
|
+
[Issues and feedback welcome.](https://github.com/shivaam/openapi-cli-gen/issues)
|
|
256
|
+
|
|
257
|
+
## License
|
|
258
|
+
|
|
259
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
openapi_cli_gen/__init__.py,sha256=IVSN7uSg6k-Wp5o3c5SfjvtKbjd5kIiQ8WNdz2h9BKI,245
|
|
2
|
+
openapi_cli_gen/__main__.py,sha256=wWAxiy6PoAGYJ30f9p2N9aAz_VxEUbu4N8ffnEZkiEI,45
|
|
3
|
+
openapi_cli_gen/cli.py,sha256=VADSyWpnPfJNAG68-WVRBUnK0UVeKYOnxKgj8BfI7N8,2184
|
|
4
|
+
openapi_cli_gen/codegen/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
openapi_cli_gen/codegen/generator.py,sha256=vjEWJW86U4w25A-K_wTRaeBmKfbh158nTAQOomnmn1Y,1036
|
|
6
|
+
openapi_cli_gen/codegen/templates/__init__.py.jinja2,sha256=8LRwD27OuseSMRaKZtrwvS0mgPXA7fiDdnvHZiqYrmM,55
|
|
7
|
+
openapi_cli_gen/codegen/templates/cli.py.jinja2,sha256=K2ujDNdg6G5ffKeJrnivcz-Que4n2bf5zfJiH1Fd3cE,216
|
|
8
|
+
openapi_cli_gen/codegen/templates/pyproject.toml.jinja2,sha256=ywQiYePi0gqSJvsa3wNQs4HJBNXHHfCD_zJurGS3GGg,316
|
|
9
|
+
openapi_cli_gen/engine/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
openapi_cli_gen/engine/auth.py,sha256=YY8RLxJ5Z1YMQm9nKRqtxwq5GMr77HJC4Hf7NDRKQpo,1756
|
|
11
|
+
openapi_cli_gen/engine/builder.py,sha256=oBj6lTHSjx87EvdSAgVsOAlPKVF51qs6xXIkaq3rmUc,4242
|
|
12
|
+
openapi_cli_gen/engine/dispatch.py,sha256=e8zmZ1QmpQ3y6WvHnkkzNDBNSw15FD2R-riI9wyg44Q,1834
|
|
13
|
+
openapi_cli_gen/engine/models.py,sha256=PX-tQbaM1buE-iSPZDVrEJYOZGjQN-RvtxEky7p2yBU,3791
|
|
14
|
+
openapi_cli_gen/engine/registry.py,sha256=D1Di4xEUdLj6uya4sG_zZLbu5JOAyKl1TdN30putZFs,3870
|
|
15
|
+
openapi_cli_gen/output/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
openapi_cli_gen/output/formatter.py,sha256=XwyMZGeVdtPHRZ5DenNT0-twUNPTvBJ3jMoAmejXo-4,1961
|
|
17
|
+
openapi_cli_gen/spec/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
openapi_cli_gen/spec/loader.py,sha256=Vp9hdFG1ONF8XgQF4_JyoEbS4IT_ucWwQHJgmED3JGQ,795
|
|
19
|
+
openapi_cli_gen/spec/parser.py,sha256=iAhMpE-CDombfg4yYYGMjNKyJG_CNg1t1Ax291A9v8c,3325
|
|
20
|
+
openapi_cli_gen-0.0.1.dist-info/METADATA,sha256=YlSutjYlewn9QfoBG4vB8bMspPGSQ32IxRgWHUMn2zw,8243
|
|
21
|
+
openapi_cli_gen-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
22
|
+
openapi_cli_gen-0.0.1.dist-info/entry_points.txt,sha256=SJdj_Damhq_WSvR7GauoATrZrgnIqE9Qtv4kdhjS7VE,61
|
|
23
|
+
openapi_cli_gen-0.0.1.dist-info/licenses/LICENSE,sha256=SK6dycQgq7_7nRsXwgZKO6kt6dtEpx6jQq2FwUd-I4k,1085
|
|
24
|
+
openapi_cli_gen-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 openapi-cli-gen contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|