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.
@@ -0,0 +1,7 @@
1
+ """Generate typed Python CLIs from OpenAPI specs with Pydantic model flattening."""
2
+
3
+ __version__ = "0.0.1"
4
+
5
+ from openapi_cli_gen.engine.builder import build_cli, build_command_group
6
+
7
+ __all__ = ["build_cli", "build_command_group", "__version__"]
@@ -0,0 +1,3 @@
1
+ from openapi_cli_gen.cli import main
2
+
3
+ main()
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
+ from pathlib import Path
2
+ from openapi_cli_gen import build_cli
3
+
4
+ app = build_cli(
5
+ spec=Path(__file__).parent / "spec.yaml",
6
+ name="{{ name }}",
7
+ )
8
+
9
+
10
+ def main():
11
+ app()
12
+
13
+
14
+ if __name__ == "__main__":
15
+ main()
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/openapi-cli-gen)](https://pypi.org/project/openapi-cli-gen/)
46
+ [![Python](https://img.shields.io/pypi/pyversions/openapi-cli-gen)](https://pypi.org/project/openapi-cli-gen/)
47
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ openapi-cli-gen = openapi_cli_gen.cli:main
@@ -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.