muapi-cli 0.2.5__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.
- muapi/__init__.py +1 -0
- muapi/client.py +121 -0
- muapi/commands/__init__.py +0 -0
- muapi/commands/account.py +89 -0
- muapi/commands/audio.py +139 -0
- muapi/commands/auth.py +193 -0
- muapi/commands/config_cmd.py +80 -0
- muapi/commands/docs.py +81 -0
- muapi/commands/edit.py +134 -0
- muapi/commands/enhance.py +157 -0
- muapi/commands/image.py +297 -0
- muapi/commands/keys.py +115 -0
- muapi/commands/mcp_server.py +905 -0
- muapi/commands/models.py +79 -0
- muapi/commands/predict.py +43 -0
- muapi/commands/run.py +173 -0
- muapi/commands/upload.py +31 -0
- muapi/commands/video.py +318 -0
- muapi/commands/workflow.py +746 -0
- muapi/config.py +110 -0
- muapi/dynamic_help.py +144 -0
- muapi/exitcodes.py +14 -0
- muapi/main.py +98 -0
- muapi/schema_introspect.py +175 -0
- muapi/utils.py +202 -0
- muapi_cli-0.2.5.dist-info/METADATA +337 -0
- muapi_cli-0.2.5.dist-info/RECORD +29 -0
- muapi_cli-0.2.5.dist-info/WHEEL +4 -0
- muapi_cli-0.2.5.dist-info/entry_points.txt +2 -0
muapi/config.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Config management — stores API key in OS keychain, with ~/.muapi/config.json fallback."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
_CONFIG_DIR = Path.home() / ".muapi"
|
|
8
|
+
_CONFIG_FILE = _CONFIG_DIR / "config.json"
|
|
9
|
+
_KEYRING_SERVICE = "muapi-cli"
|
|
10
|
+
_KEYRING_USER = "api-key"
|
|
11
|
+
|
|
12
|
+
BASE_URL = os.environ.get("MUAPI_BASE_URL", "https://api.muapi.ai/api/v1")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _try_keyring() -> tuple[bool, Optional[str]]:
|
|
16
|
+
try:
|
|
17
|
+
import keyring
|
|
18
|
+
val = keyring.get_password(_KEYRING_SERVICE, _KEYRING_USER)
|
|
19
|
+
return True, val
|
|
20
|
+
except Exception:
|
|
21
|
+
return False, None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_api_key() -> Optional[str]:
|
|
25
|
+
# 1. Environment variable always wins
|
|
26
|
+
if key := os.environ.get("MUAPI_API_KEY"):
|
|
27
|
+
return key
|
|
28
|
+
# 2. OS keychain
|
|
29
|
+
ok, val = _try_keyring()
|
|
30
|
+
if ok and val:
|
|
31
|
+
return val
|
|
32
|
+
# 3. Config file fallback
|
|
33
|
+
if _CONFIG_FILE.exists():
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(_CONFIG_FILE.read_text())
|
|
36
|
+
return data.get("api_key")
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_api_key(api_key: str) -> str:
|
|
43
|
+
"""Save API key; returns where it was saved ('keychain' or 'file')."""
|
|
44
|
+
ok, _ = _try_keyring()
|
|
45
|
+
if ok:
|
|
46
|
+
import keyring
|
|
47
|
+
keyring.set_password(_KEYRING_SERVICE, _KEYRING_USER, api_key)
|
|
48
|
+
return "keychain"
|
|
49
|
+
# Fallback: write to file
|
|
50
|
+
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
existing: dict = {}
|
|
52
|
+
if _CONFIG_FILE.exists():
|
|
53
|
+
try:
|
|
54
|
+
existing = json.loads(_CONFIG_FILE.read_text())
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
existing["api_key"] = api_key
|
|
58
|
+
_CONFIG_FILE.write_text(json.dumps(existing, indent=2))
|
|
59
|
+
_CONFIG_FILE.chmod(0o600)
|
|
60
|
+
return "file"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_setting(key: str) -> Optional[str]:
|
|
64
|
+
"""Read a value from the settings section of the config file."""
|
|
65
|
+
if _CONFIG_FILE.exists():
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(_CONFIG_FILE.read_text())
|
|
68
|
+
return data.get("settings", {}).get(key)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def set_setting(key: str, value: str) -> None:
|
|
75
|
+
"""Write a value to the settings section of the config file."""
|
|
76
|
+
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
existing: dict = {}
|
|
78
|
+
if _CONFIG_FILE.exists():
|
|
79
|
+
try:
|
|
80
|
+
existing = json.loads(_CONFIG_FILE.read_text())
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
existing.setdefault("settings", {})[key] = value
|
|
84
|
+
_CONFIG_FILE.write_text(json.dumps(existing, indent=2))
|
|
85
|
+
_CONFIG_FILE.chmod(0o600)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_all_settings() -> dict:
|
|
89
|
+
"""Return all settings as a dict."""
|
|
90
|
+
if _CONFIG_FILE.exists():
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(_CONFIG_FILE.read_text())
|
|
93
|
+
return data.get("settings", {})
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def delete_api_key() -> None:
|
|
100
|
+
ok, _ = _try_keyring()
|
|
101
|
+
if ok:
|
|
102
|
+
try:
|
|
103
|
+
import keyring
|
|
104
|
+
keyring.delete_password(_KEYRING_SERVICE, _KEYRING_USER)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
if _CONFIG_FILE.exists():
|
|
108
|
+
data = json.loads(_CONFIG_FILE.read_text())
|
|
109
|
+
data.pop("api_key", None)
|
|
110
|
+
_CONFIG_FILE.write_text(json.dumps(data, indent=2))
|
muapi/dynamic_help.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Dynamic per-model `--help` for `muapi run <model>`.
|
|
2
|
+
|
|
3
|
+
Typer's static help only knows about flags that are *declared* on a command,
|
|
4
|
+
so it can't show per-model inputs. We sniff argv before Typer runs, and if
|
|
5
|
+
the user is asking for help on `muapi run <model>`, we fetch the model's
|
|
6
|
+
OpenAPI schema and print its real properties.
|
|
7
|
+
|
|
8
|
+
If anything goes wrong (network, unknown model, malformed spec), we return
|
|
9
|
+
False so the caller falls through to Typer's normal static help.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from . import schema_introspect
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_HELP_FLAGS = {"-h", "--help"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect_run_help(argv: list[str]) -> Optional[str]:
|
|
26
|
+
"""Return the model token if argv looks like `muapi run <model> -h`.
|
|
27
|
+
|
|
28
|
+
The check is intentionally permissive — extra flags between `run`
|
|
29
|
+
and `-h` don't disqualify (e.g. `run flux-dev -p foo -h`).
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
run_idx = argv.index("run")
|
|
33
|
+
except ValueError:
|
|
34
|
+
return None
|
|
35
|
+
rest = argv[run_idx + 1:]
|
|
36
|
+
if not rest:
|
|
37
|
+
return None
|
|
38
|
+
if not any(flag in rest for flag in _HELP_FLAGS):
|
|
39
|
+
return None
|
|
40
|
+
# First non-flag token after `run` is the model.
|
|
41
|
+
for token in rest:
|
|
42
|
+
if token in _HELP_FLAGS:
|
|
43
|
+
return None # asked for help with no model
|
|
44
|
+
if not token.startswith("-"):
|
|
45
|
+
return token
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def print_dynamic_help(model: str) -> bool:
|
|
50
|
+
"""Print schema-driven help for `model`. Returns True on success."""
|
|
51
|
+
# Resolve aliases the same way the run command does.
|
|
52
|
+
try:
|
|
53
|
+
from .commands.run import resolve_model
|
|
54
|
+
endpoint = resolve_model(model)
|
|
55
|
+
except Exception:
|
|
56
|
+
endpoint = model
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
described = schema_introspect.lookup(endpoint)
|
|
60
|
+
except Exception:
|
|
61
|
+
return False
|
|
62
|
+
if not described:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
console = Console(stderr=False)
|
|
66
|
+
console.print()
|
|
67
|
+
console.print(f"[bold magenta]muapi run {model}[/bold magenta]")
|
|
68
|
+
if endpoint != model:
|
|
69
|
+
console.print(f" [dim]endpoint:[/dim] {endpoint}")
|
|
70
|
+
if described["title"] and described["title"] != endpoint:
|
|
71
|
+
console.print(f" [dim]schema: [/dim] {described['title']}")
|
|
72
|
+
console.print()
|
|
73
|
+
console.print("[bold]Usage:[/bold] muapi run "
|
|
74
|
+
f"{model} [-p PROMPT] [-i KEY=VALUE ...] [--input-file FILE] [global opts]")
|
|
75
|
+
console.print()
|
|
76
|
+
|
|
77
|
+
props = described["properties"]
|
|
78
|
+
if not props:
|
|
79
|
+
console.print("[dim]No input properties documented for this endpoint.[/dim]")
|
|
80
|
+
console.print()
|
|
81
|
+
else:
|
|
82
|
+
table = Table(show_header=True, header_style="bold cyan", title="Inputs (from live OpenAPI schema)")
|
|
83
|
+
table.add_column("name")
|
|
84
|
+
table.add_column("type")
|
|
85
|
+
table.add_column("required")
|
|
86
|
+
table.add_column("default")
|
|
87
|
+
table.add_column("description / enum")
|
|
88
|
+
for p in props:
|
|
89
|
+
default = "" if p["default"] is None else _short(p["default"])
|
|
90
|
+
desc = p["description"]
|
|
91
|
+
if p["enum"]:
|
|
92
|
+
desc = (desc + " " if desc else "") + f"[dim]enum:[/dim] {', '.join(map(str, p['enum']))}"
|
|
93
|
+
table.add_row(
|
|
94
|
+
p["name"],
|
|
95
|
+
p["type"],
|
|
96
|
+
"[red]✓[/red]" if p["required"] else "",
|
|
97
|
+
default,
|
|
98
|
+
desc,
|
|
99
|
+
)
|
|
100
|
+
console.print(table)
|
|
101
|
+
console.print()
|
|
102
|
+
|
|
103
|
+
console.print("[bold]Global options:[/bold]")
|
|
104
|
+
console.print(" -p, --prompt TEXT Prompt (also pass via -i prompt=...). Use '-' for stdin.")
|
|
105
|
+
console.print(" -i, --input KEY=VALUE Repeatable. Values are parsed as JSON when valid.")
|
|
106
|
+
console.print(" --input-file FILE JSON file of inputs (merged before -i flags).")
|
|
107
|
+
console.print(" --wait / --no-wait Poll until done (default: --wait).")
|
|
108
|
+
console.print(" --dry-run Print the request that would be sent and exit.")
|
|
109
|
+
console.print(" --download DIR, -d Save outputs to DIR.")
|
|
110
|
+
console.print(" --output-json, -j Print raw JSON to stdout.")
|
|
111
|
+
console.print(" --jq EXPR jq-style filter on JSON output.")
|
|
112
|
+
console.print()
|
|
113
|
+
console.print("[dim]Example:[/dim] muapi run "
|
|
114
|
+
f"{model} -p \"...\" {_example_inputs(props)}--output-json")
|
|
115
|
+
console.print()
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _short(val: object) -> str:
|
|
120
|
+
s = repr(val) if isinstance(val, str) else str(val)
|
|
121
|
+
return s if len(s) <= 32 else s[:29] + "..."
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _example_inputs(props: list[dict]) -> str:
|
|
125
|
+
"""Suggest a couple of `-i` flags from the schema as an example."""
|
|
126
|
+
bits = []
|
|
127
|
+
for p in props:
|
|
128
|
+
if p["name"] == "prompt":
|
|
129
|
+
continue
|
|
130
|
+
if p["default"] is not None and not isinstance(p["default"], (list, dict)):
|
|
131
|
+
bits.append(f"-i {p['name']}={p['default']}")
|
|
132
|
+
elif p["enum"]:
|
|
133
|
+
bits.append(f"-i {p['name']}={p['enum'][0]}")
|
|
134
|
+
if len(bits) >= 2:
|
|
135
|
+
break
|
|
136
|
+
return (" ".join(bits) + " ") if bits else ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def maybe_handle_run_help(argv: Optional[list[str]] = None) -> bool:
|
|
140
|
+
"""Top-level entry: if argv asks for `run <model> -h`, print + return True."""
|
|
141
|
+
model = detect_run_help(list(argv) if argv is not None else sys.argv)
|
|
142
|
+
if not model:
|
|
143
|
+
return False
|
|
144
|
+
return print_dynamic_help(model)
|
muapi/exitcodes.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Semantic exit codes for agent-friendly scripting.
|
|
2
|
+
|
|
3
|
+
Exit codes match the convention established by modelslab-cli and common
|
|
4
|
+
Unix tool practices so agents can branch on specific failure types.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
OK = 0 # Success
|
|
8
|
+
ERROR = 1 # Generic / unclassified error
|
|
9
|
+
AUTH_ERROR = 3 # Missing or invalid API key
|
|
10
|
+
RATE_LIMITED = 4 # 429 Too Many Requests
|
|
11
|
+
NOT_FOUND = 5 # 404 — resource or model not found
|
|
12
|
+
BILLING_ERROR = 6 # Insufficient credits / payment required
|
|
13
|
+
TIMEOUT = 7 # Generation timed out
|
|
14
|
+
VALIDATION = 8 # Bad input / schema validation failure
|
muapi/main.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""muapi CLI — official command-line interface for muapi.ai"""
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich import print as rprint
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .commands import auth, account, audio, config_cmd, docs, edit, enhance, image, keys, models, predict, run, upload, video, workflow
|
|
9
|
+
from .commands import mcp_server
|
|
10
|
+
from .dynamic_help import maybe_handle_run_help
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="muapi",
|
|
14
|
+
help="muapi.ai CLI — generate images, videos, and audio from the terminal.",
|
|
15
|
+
add_completion=True,
|
|
16
|
+
rich_markup_mode="rich",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# ── Subcommand groups ──────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
app.add_typer(auth.app, name="auth", help="Log in, register, or configure API key.")
|
|
24
|
+
app.add_typer(account.app, name="account", help="Check balance and top up credits.")
|
|
25
|
+
app.add_typer(keys.app, name="keys", help="List, create, and delete API keys.")
|
|
26
|
+
app.add_typer(image.app, name="image", help="Generate or edit images.")
|
|
27
|
+
app.add_typer(video.app, name="video", help="Generate videos from text or images.")
|
|
28
|
+
app.add_typer(audio.app, name="audio", help="Create or remix music and audio.")
|
|
29
|
+
app.add_typer(enhance.app, name="enhance", help="Enhance images (upscale, bg-remove, face-swap…).")
|
|
30
|
+
app.add_typer(edit.app, name="edit", help="Edit videos (effects, lipsync, dance, dress…).")
|
|
31
|
+
app.add_typer(predict.app, name="predict", help="Check or wait for async prediction results.")
|
|
32
|
+
|
|
33
|
+
# `run` is a single top-level command, not a group, so its positional MODEL
|
|
34
|
+
# argument doesn't collide with subcommand routing.
|
|
35
|
+
app.command(
|
|
36
|
+
"run",
|
|
37
|
+
help="Run any model by endpoint name (schema-driven; try `muapi run <model> -h`).",
|
|
38
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
39
|
+
)(run.run)
|
|
40
|
+
app.add_typer(upload.app, name="upload", help="Upload local files to get a hosted URL.")
|
|
41
|
+
app.add_typer(models.app, name="models", help="Discover all available models.")
|
|
42
|
+
app.add_typer(workflow.app, name="workflow", help="Build, run, and visualize multi-step AI workflows.")
|
|
43
|
+
app.add_typer(config_cmd.app, name="config", help="Get and set persistent CLI configuration.")
|
|
44
|
+
app.add_typer(docs.app, name="docs", help="Access the muapi.ai API documentation.")
|
|
45
|
+
app.add_typer(mcp_server.app, name="mcp", help="Run as an MCP server for AI agent integration.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command("version")
|
|
49
|
+
def version(
|
|
50
|
+
output_json: bool = typer.Option(False, "--output-json", "-j"),
|
|
51
|
+
):
|
|
52
|
+
"""Show the muapi CLI version."""
|
|
53
|
+
if output_json:
|
|
54
|
+
import json
|
|
55
|
+
from .utils import out
|
|
56
|
+
out.print_json(json.dumps({"version": __version__, "name": "muapi-cli"}))
|
|
57
|
+
else:
|
|
58
|
+
rprint(f"muapi CLI [bold]{__version__}[/bold]")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.callback(invoke_without_command=True)
|
|
62
|
+
def main(
|
|
63
|
+
ctx: typer.Context,
|
|
64
|
+
no_color: bool = typer.Option(
|
|
65
|
+
False, "--no-color",
|
|
66
|
+
help="Disable colored output (also respects NO_COLOR env var)",
|
|
67
|
+
is_eager=True,
|
|
68
|
+
),
|
|
69
|
+
version_flag: bool = typer.Option(
|
|
70
|
+
False, "--version", "-V",
|
|
71
|
+
help="Show version and exit",
|
|
72
|
+
is_eager=True,
|
|
73
|
+
),
|
|
74
|
+
):
|
|
75
|
+
if no_color:
|
|
76
|
+
from .utils import disable_color
|
|
77
|
+
disable_color()
|
|
78
|
+
|
|
79
|
+
if version_flag:
|
|
80
|
+
rprint(f"muapi CLI [bold]{__version__}[/bold]")
|
|
81
|
+
raise typer.Exit()
|
|
82
|
+
|
|
83
|
+
if ctx.invoked_subcommand is None:
|
|
84
|
+
rprint(ctx.get_help())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _entrypoint() -> None:
|
|
88
|
+
# Intercept `muapi run <model> -h` so we can print model-specific
|
|
89
|
+
# input help from the live OpenAPI schema. Falls through to Typer
|
|
90
|
+
# on any failure (network down, unknown model, missing schema).
|
|
91
|
+
import sys
|
|
92
|
+
if maybe_handle_run_help(sys.argv[1:]):
|
|
93
|
+
return
|
|
94
|
+
app()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
_entrypoint()
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Fetch the muapi OpenAPI spec and resolve per-endpoint request schemas.
|
|
2
|
+
|
|
3
|
+
The CLI's static verb commands wrap a curated subset of models. `muapi run`
|
|
4
|
+
needs to reach *any* endpoint exposed by the API and discover its input
|
|
5
|
+
schema at call time — so we read the live OpenAPI spec and look up the
|
|
6
|
+
request body for the endpoint the user named.
|
|
7
|
+
|
|
8
|
+
The spec is cached on disk (~/.muapi/openapi-cache.json, 1h TTL) so repeated
|
|
9
|
+
`muapi run ... -h` calls don't hit the network.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from .config import BASE_URL
|
|
21
|
+
|
|
22
|
+
_HOST = BASE_URL.replace("/api/v1", "")
|
|
23
|
+
_OPENAPI_URL = f"{_HOST}/openapi.json"
|
|
24
|
+
|
|
25
|
+
_CACHE_DIR = Path.home() / ".muapi"
|
|
26
|
+
_CACHE_FILE = _CACHE_DIR / "openapi-cache.json"
|
|
27
|
+
_CACHE_TTL = 3600 # 1 hour, matches WaveSpeed's `models.json` cache
|
|
28
|
+
|
|
29
|
+
_API_PREFIX = "/api/v1/"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_cache() -> Optional[dict]:
|
|
33
|
+
if not _CACHE_FILE.exists():
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
wrapper = json.loads(_CACHE_FILE.read_text())
|
|
37
|
+
if time.time() - wrapper.get("fetched_at", 0) > _CACHE_TTL:
|
|
38
|
+
return None
|
|
39
|
+
if wrapper.get("base_url") != BASE_URL:
|
|
40
|
+
return None
|
|
41
|
+
return wrapper.get("spec")
|
|
42
|
+
except Exception:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _save_cache(spec: dict) -> None:
|
|
47
|
+
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
payload = {"base_url": BASE_URL, "fetched_at": time.time(), "spec": spec}
|
|
49
|
+
try:
|
|
50
|
+
_CACHE_FILE.write_text(json.dumps(payload))
|
|
51
|
+
except Exception:
|
|
52
|
+
pass # cache failure shouldn't break the call
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fetch_spec(force_refresh: bool = False, timeout: float = 15.0) -> dict:
|
|
56
|
+
"""Return the OpenAPI spec, served from cache when fresh."""
|
|
57
|
+
if not force_refresh:
|
|
58
|
+
cached = _load_cache()
|
|
59
|
+
if cached:
|
|
60
|
+
return cached
|
|
61
|
+
resp = httpx.get(_OPENAPI_URL, timeout=timeout)
|
|
62
|
+
resp.raise_for_status()
|
|
63
|
+
spec = resp.json()
|
|
64
|
+
_save_cache(spec)
|
|
65
|
+
return spec
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ── Lookup ────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
def _resolve_ref(spec: dict, ref: str) -> dict:
|
|
71
|
+
# "#/components/schemas/ImageRequest" → dict
|
|
72
|
+
if not ref.startswith("#/"):
|
|
73
|
+
return {}
|
|
74
|
+
node: Any = spec
|
|
75
|
+
for part in ref[2:].split("/"):
|
|
76
|
+
if not isinstance(node, dict) or part not in node:
|
|
77
|
+
return {}
|
|
78
|
+
node = node[part]
|
|
79
|
+
return node if isinstance(node, dict) else {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def find_endpoint(spec: dict, endpoint: str) -> Optional[dict]:
|
|
83
|
+
"""Locate the POST operation for an endpoint name (with or without /api/v1/ prefix)."""
|
|
84
|
+
paths = spec.get("paths", {})
|
|
85
|
+
# Try a few common forms — users pass the endpoint slug, not the full path.
|
|
86
|
+
candidates = [endpoint]
|
|
87
|
+
if not endpoint.startswith("/"):
|
|
88
|
+
candidates.append(f"{_API_PREFIX}{endpoint}")
|
|
89
|
+
candidates.append(f"/{endpoint}")
|
|
90
|
+
for candidate in candidates:
|
|
91
|
+
node = paths.get(candidate)
|
|
92
|
+
if node and "post" in node:
|
|
93
|
+
return node["post"]
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_request_schema(spec: dict, endpoint: str) -> Optional[dict]:
|
|
98
|
+
"""Return the resolved JSON schema for the endpoint's request body, or None."""
|
|
99
|
+
op = find_endpoint(spec, endpoint)
|
|
100
|
+
if not op:
|
|
101
|
+
return None
|
|
102
|
+
body = op.get("requestBody", {})
|
|
103
|
+
content = body.get("content", {}).get("application/json", {})
|
|
104
|
+
schema = content.get("schema", {})
|
|
105
|
+
# Follow a single $ref hop — that's all muapi's spec uses today.
|
|
106
|
+
if "$ref" in schema:
|
|
107
|
+
return _resolve_ref(spec, schema["$ref"])
|
|
108
|
+
return schema or None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _format_type(prop: dict) -> str:
|
|
112
|
+
"""Best-effort one-line type label for a JSON schema property."""
|
|
113
|
+
if "enum" in prop:
|
|
114
|
+
return "enum"
|
|
115
|
+
if "anyOf" in prop:
|
|
116
|
+
types = []
|
|
117
|
+
for sub in prop["anyOf"]:
|
|
118
|
+
t = sub.get("type")
|
|
119
|
+
if t and t != "null":
|
|
120
|
+
types.append(t)
|
|
121
|
+
return " | ".join(types) if types else "any"
|
|
122
|
+
if "type" in prop:
|
|
123
|
+
t = prop["type"]
|
|
124
|
+
if t == "array":
|
|
125
|
+
item_type = prop.get("items", {}).get("type", "any")
|
|
126
|
+
return f"array<{item_type}>"
|
|
127
|
+
return t
|
|
128
|
+
if "$ref" in prop:
|
|
129
|
+
ref = prop["$ref"].rsplit("/", 1)[-1]
|
|
130
|
+
return f"object<{ref}>"
|
|
131
|
+
return "any"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def describe_schema(schema: dict) -> dict:
|
|
135
|
+
"""Normalize a JSON schema for display.
|
|
136
|
+
|
|
137
|
+
Returns: {title, properties: [(name, type, required, default, enum, description)]}
|
|
138
|
+
"""
|
|
139
|
+
title = schema.get("title", "")
|
|
140
|
+
required = set(schema.get("required", []))
|
|
141
|
+
rows = []
|
|
142
|
+
for name, prop in schema.get("properties", {}).items():
|
|
143
|
+
if not isinstance(prop, dict):
|
|
144
|
+
continue
|
|
145
|
+
rows.append({
|
|
146
|
+
"name": name,
|
|
147
|
+
"type": _format_type(prop),
|
|
148
|
+
"required": name in required,
|
|
149
|
+
"default": prop.get("default", None),
|
|
150
|
+
"enum": prop.get("enum"),
|
|
151
|
+
"description": prop.get("description") or prop.get("title") or "",
|
|
152
|
+
})
|
|
153
|
+
# Sort: required first, then alphabetical.
|
|
154
|
+
rows.sort(key=lambda r: (not r["required"], r["name"]))
|
|
155
|
+
return {"title": title, "properties": rows}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── Public convenience ───────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
def lookup(endpoint: str, *, force_refresh: bool = False) -> Optional[dict]:
|
|
161
|
+
"""Fetch + extract + describe in one call. Returns None if not found."""
|
|
162
|
+
spec = fetch_spec(force_refresh=force_refresh)
|
|
163
|
+
schema = get_request_schema(spec, endpoint)
|
|
164
|
+
if not schema:
|
|
165
|
+
return None
|
|
166
|
+
return describe_schema(schema)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def list_endpoint_slugs(spec: dict) -> list[str]:
|
|
170
|
+
"""Return every POST endpoint slug under /api/v1/."""
|
|
171
|
+
slugs = []
|
|
172
|
+
for path in spec.get("paths", {}):
|
|
173
|
+
if path.startswith(_API_PREFIX) and "post" in spec["paths"][path]:
|
|
174
|
+
slugs.append(path[len(_API_PREFIX):])
|
|
175
|
+
return slugs
|