insightfactory-cli 1.0.0.dev1__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.
- if_cli/__init__.py +1 -0
- if_cli/__main__.py +4 -0
- if_cli/cache.py +206 -0
- if_cli/cli.py +68 -0
- if_cli/colour.py +24 -0
- if_cli/commands/__init__.py +0 -0
- if_cli/commands/api.py +177 -0
- if_cli/commands/config.py +45 -0
- if_cli/commands/login.py +75 -0
- if_cli/commands/logout.py +20 -0
- if_cli/commands/profiles.py +119 -0
- if_cli/commands/set_token.py +62 -0
- if_cli/commands/token.py +32 -0
- if_cli/config.py +188 -0
- if_cli/constants.py +5 -0
- if_cli/http.py +153 -0
- if_cli/main.py +76 -0
- if_cli/oauth.py +389 -0
- if_cli/runtime.py +94 -0
- insightfactory_cli-1.0.0.dev1.dist-info/METADATA +265 -0
- insightfactory_cli-1.0.0.dev1.dist-info/RECORD +23 -0
- insightfactory_cli-1.0.0.dev1.dist-info/WHEEL +4 -0
- insightfactory_cli-1.0.0.dev1.dist-info/entry_points.txt +2 -0
if_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""InsightFactory CLI."""
|
if_cli/__main__.py
ADDED
if_cli/cache.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import TypedDict, TypeGuard, TypeVar
|
|
9
|
+
|
|
10
|
+
from if_cli.config import cache_file, write_private_file
|
|
11
|
+
from if_cli.constants import EXPIRY_SLACK_SECONDS
|
|
12
|
+
from if_cli.runtime import die, is_record
|
|
13
|
+
|
|
14
|
+
CACHE_LOCK_STALE_MS = 30_000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CacheEntry(TypedDict, total=False):
|
|
18
|
+
access_token: str
|
|
19
|
+
refresh_token: str
|
|
20
|
+
expires_at: float
|
|
21
|
+
token_endpoint: str
|
|
22
|
+
client_id: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TokenCache(TypedDict):
|
|
26
|
+
version: int
|
|
27
|
+
tokens: dict[str, CacheEntry]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _positive_integer_env(name: str, fallback: int) -> int:
|
|
31
|
+
raw = os.environ.get(name)
|
|
32
|
+
if raw is None:
|
|
33
|
+
return fallback
|
|
34
|
+
try:
|
|
35
|
+
value = float(raw)
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
return fallback
|
|
38
|
+
if math.isfinite(value) and value == int(value) and value > 0:
|
|
39
|
+
return int(value)
|
|
40
|
+
return fallback
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cache_lock_file() -> str:
|
|
44
|
+
return f"{cache_file()}.lock"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _cache_lock_attempts() -> int:
|
|
48
|
+
return _positive_integer_env("INSIGHTFACTORY_CACHE_LOCK_ATTEMPTS", 200)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _cache_lock_retry_ms() -> int:
|
|
52
|
+
return _positive_integer_env("INSIGHTFACTORY_CACHE_LOCK_RETRY_MS", 25)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_fresh(entry: CacheEntry, now_seconds: float | None = None) -> bool:
|
|
56
|
+
if now_seconds is None:
|
|
57
|
+
now_seconds = time.time()
|
|
58
|
+
return entry["expires_at"] - EXPIRY_SLACK_SECONDS > now_seconds
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_cache_entry(value: object) -> TypeGuard[CacheEntry]:
|
|
62
|
+
if not is_record(value):
|
|
63
|
+
return False
|
|
64
|
+
access_token = value.get("access_token")
|
|
65
|
+
expires_at = value.get("expires_at")
|
|
66
|
+
token_endpoint = value.get("token_endpoint")
|
|
67
|
+
refresh_token = value.get("refresh_token")
|
|
68
|
+
client_id = value.get("client_id")
|
|
69
|
+
return (
|
|
70
|
+
isinstance(access_token, str)
|
|
71
|
+
and isinstance(expires_at, (int, float))
|
|
72
|
+
and not isinstance(expires_at, bool)
|
|
73
|
+
and math.isfinite(float(expires_at))
|
|
74
|
+
and isinstance(token_endpoint, str)
|
|
75
|
+
and ("refresh_token" not in value or isinstance(refresh_token, str))
|
|
76
|
+
and ("client_id" not in value or isinstance(client_id, str))
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_cache(raw: str) -> TokenCache:
|
|
81
|
+
value: object = json.loads(raw)
|
|
82
|
+
if not is_record(value):
|
|
83
|
+
die(f"token cache {cache_file()} has an invalid structure; move it aside and log in again")
|
|
84
|
+
tokens_value = value.get("tokens")
|
|
85
|
+
if value.get("version") != 1 or not is_record(tokens_value):
|
|
86
|
+
die(f"token cache {cache_file()} has an invalid structure; move it aside and log in again")
|
|
87
|
+
tokens: dict[str, CacheEntry] = {}
|
|
88
|
+
for host, entry in tokens_value.items():
|
|
89
|
+
if not _is_cache_entry(entry):
|
|
90
|
+
die(f"token cache entry for {host} is invalid; move {cache_file()} aside and log in again")
|
|
91
|
+
tokens[host] = entry
|
|
92
|
+
return {"version": 1, "tokens": tokens}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_cache() -> TokenCache:
|
|
96
|
+
try:
|
|
97
|
+
with open(cache_file(), encoding="utf-8") as handle:
|
|
98
|
+
return _parse_cache(handle.read())
|
|
99
|
+
except FileNotFoundError:
|
|
100
|
+
return {"version": 1, "tokens": {}}
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
die(f"token cache {cache_file()} is not valid JSON; move it aside and log in again")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _save_cache(cache: TokenCache) -> None:
|
|
106
|
+
write_private_file(cache_file(), f"{json.dumps(cache, indent=2)}\n")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
T = TypeVar("T")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def with_cache_lock(operation: Callable[[], T]) -> T:
|
|
113
|
+
lock_file = _cache_lock_file()
|
|
114
|
+
os.makedirs(os.path.dirname(lock_file), mode=0o700, exist_ok=True)
|
|
115
|
+
attempts = _cache_lock_attempts()
|
|
116
|
+
retry_ms = _cache_lock_retry_ms()
|
|
117
|
+
for _attempt in range(attempts):
|
|
118
|
+
try:
|
|
119
|
+
descriptor = os.open(lock_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
120
|
+
except FileExistsError:
|
|
121
|
+
try:
|
|
122
|
+
mtime_ms = os.stat(lock_file).st_mtime * 1000
|
|
123
|
+
if time.time() * 1000 - mtime_ms > CACHE_LOCK_STALE_MS:
|
|
124
|
+
os.unlink(lock_file)
|
|
125
|
+
continue
|
|
126
|
+
except OSError:
|
|
127
|
+
pass
|
|
128
|
+
time.sleep(retry_ms / 1000)
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
return operation()
|
|
132
|
+
finally:
|
|
133
|
+
os.close(descriptor)
|
|
134
|
+
try:
|
|
135
|
+
os.unlink(lock_file)
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
die(f"timed out waiting for token cache lock {lock_file}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def clear_token(host: str) -> bool:
|
|
142
|
+
def operation() -> bool:
|
|
143
|
+
cache = load_cache()
|
|
144
|
+
if host not in cache["tokens"]:
|
|
145
|
+
return False
|
|
146
|
+
del cache["tokens"][host]
|
|
147
|
+
_save_cache(cache)
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
return with_cache_lock(operation)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def clear_refresh_token_if_matches(host: str, rejected_token: str) -> bool:
|
|
154
|
+
def operation() -> bool:
|
|
155
|
+
cache = load_cache()
|
|
156
|
+
entry = cache["tokens"].get(host)
|
|
157
|
+
if not entry or entry.get("refresh_token") != rejected_token:
|
|
158
|
+
return False
|
|
159
|
+
entry.pop("refresh_token", None)
|
|
160
|
+
_save_cache(cache)
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
return with_cache_lock(operation)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def store_tokens(
|
|
167
|
+
host: str,
|
|
168
|
+
token_response: dict[str, object],
|
|
169
|
+
token_endpoint: str,
|
|
170
|
+
client_id: str | None = None,
|
|
171
|
+
*,
|
|
172
|
+
replace: bool = False,
|
|
173
|
+
) -> CacheEntry:
|
|
174
|
+
def operation() -> CacheEntry:
|
|
175
|
+
cache = load_cache()
|
|
176
|
+
previous = None if replace else cache["tokens"].get(host)
|
|
177
|
+
expires_in_raw = token_response.get("expires_in")
|
|
178
|
+
if (
|
|
179
|
+
isinstance(expires_in_raw, bool)
|
|
180
|
+
or not isinstance(expires_in_raw, (int, float))
|
|
181
|
+
or not math.isfinite(float(expires_in_raw))
|
|
182
|
+
):
|
|
183
|
+
expires_in: int | float = 3600
|
|
184
|
+
else:
|
|
185
|
+
expires_in = expires_in_raw
|
|
186
|
+
entry: CacheEntry = previous.copy() if previous is not None else {}
|
|
187
|
+
access_token = token_response.get("access_token")
|
|
188
|
+
if not isinstance(access_token, str):
|
|
189
|
+
die("token response did not include a valid access token")
|
|
190
|
+
entry["access_token"] = access_token
|
|
191
|
+
entry["expires_at"] = int(math.floor(time.time()) + expires_in)
|
|
192
|
+
entry["token_endpoint"] = token_endpoint
|
|
193
|
+
if client_id:
|
|
194
|
+
entry["client_id"] = client_id
|
|
195
|
+
else:
|
|
196
|
+
entry.pop("client_id", None)
|
|
197
|
+
refresh_token = token_response.get("refresh_token")
|
|
198
|
+
if isinstance(refresh_token, str) and refresh_token:
|
|
199
|
+
entry["refresh_token"] = refresh_token
|
|
200
|
+
elif replace:
|
|
201
|
+
entry.pop("refresh_token", None)
|
|
202
|
+
cache["tokens"][host] = entry
|
|
203
|
+
_save_cache(cache)
|
|
204
|
+
return entry
|
|
205
|
+
|
|
206
|
+
return with_cache_lock(operation)
|
if_cli/cli.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, NoReturn
|
|
7
|
+
|
|
8
|
+
from if_cli.runtime import die, get_exit_code, handle_broken_pipe, install_broken_pipe_handlers
|
|
9
|
+
|
|
10
|
+
Options = dict[str, dict[str, Any]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _ArgumentParser(argparse.ArgumentParser):
|
|
14
|
+
def error(self, message: str) -> NoReturn:
|
|
15
|
+
# Node util.parseArgs reports "Unknown option '--flag'". argparse says
|
|
16
|
+
# "unrecognized arguments: --flag"; rewrite the common case so the two
|
|
17
|
+
# CLIs fail with the same wording.
|
|
18
|
+
if message.startswith("unrecognized arguments: "):
|
|
19
|
+
unknown = message.removeprefix("unrecognized arguments: ").split()[0]
|
|
20
|
+
die(f"Unknown option '{unknown}'")
|
|
21
|
+
die(message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_args(
|
|
25
|
+
argv: list[str],
|
|
26
|
+
*,
|
|
27
|
+
options: Options,
|
|
28
|
+
allow_positionals: bool = False,
|
|
29
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
30
|
+
"""Parse argv with argparse, using the same flags as the TypeScript parseArgs specs."""
|
|
31
|
+
parser = _ArgumentParser(add_help=False, allow_abbrev=False)
|
|
32
|
+
for name, spec in options.items():
|
|
33
|
+
flags = [f"--{name}"]
|
|
34
|
+
short = spec.get("short")
|
|
35
|
+
if short:
|
|
36
|
+
flags.append(f"-{short}")
|
|
37
|
+
dest = name.replace("-", "_")
|
|
38
|
+
if spec["type"] == "boolean":
|
|
39
|
+
parser.add_argument(*flags, dest=dest, action="store_true", default=spec.get("default", False))
|
|
40
|
+
else:
|
|
41
|
+
parser.add_argument(*flags, dest=dest, default=spec.get("default"))
|
|
42
|
+
if allow_positionals:
|
|
43
|
+
parser.add_argument("positionals", nargs="*")
|
|
44
|
+
namespace = parser.parse_args(argv)
|
|
45
|
+
raw = vars(namespace)
|
|
46
|
+
values = {name: raw.get(name.replace("-", "_")) for name in options}
|
|
47
|
+
if allow_positionals:
|
|
48
|
+
return values, list(raw.get("positionals") or [])
|
|
49
|
+
return values, []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_with_handlers(run_cli: Callable[[list[str]], None], argv: list[str] | None = None) -> None:
|
|
53
|
+
install_broken_pipe_handlers()
|
|
54
|
+
try:
|
|
55
|
+
run_cli(sys.argv[1:] if argv is None else argv)
|
|
56
|
+
# Force any buffered EPIPE to raise here. Python 3.14 can keep the
|
|
57
|
+
# entire command output in the TextIOWrapper until shutdown, where an
|
|
58
|
+
# unhandled BrokenPipeError becomes exit status 120.
|
|
59
|
+
sys.stdout.flush()
|
|
60
|
+
except BrokenPipeError:
|
|
61
|
+
handle_broken_pipe()
|
|
62
|
+
except Exception as error:
|
|
63
|
+
try:
|
|
64
|
+
sys.stderr.write(f"if-cli: {error}\n")
|
|
65
|
+
except BrokenPipeError:
|
|
66
|
+
handle_broken_pipe()
|
|
67
|
+
raise SystemExit(1) from None
|
|
68
|
+
raise SystemExit(get_exit_code())
|
if_cli/colour.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
Colour = Literal["green", "yellow", "red"]
|
|
8
|
+
CODES: dict[Colour, int] = {"green": 32, "yellow": 33, "red": 31}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def colours_enabled() -> bool:
|
|
12
|
+
no_color = os.environ.get("NO_COLOR")
|
|
13
|
+
if no_color is not None and no_color != "":
|
|
14
|
+
return False
|
|
15
|
+
force_color = os.environ.get("FORCE_COLOR")
|
|
16
|
+
if force_color is not None and force_color != "0":
|
|
17
|
+
return True
|
|
18
|
+
return sys.stdout.isatty() and os.environ.get("TERM") != "dumb"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def colourise(value: str, colour: Colour) -> str:
|
|
22
|
+
if not colours_enabled():
|
|
23
|
+
return value
|
|
24
|
+
return f"\033[{CODES[colour]}m{value}\033[0m"
|
|
File without changes
|
if_cli/commands/api.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import ParseResult
|
|
8
|
+
|
|
9
|
+
from if_cli.cli import Options, parse_args
|
|
10
|
+
from if_cli.config import get_profile, load_config, resolve_profile_name
|
|
11
|
+
from if_cli.http import fetch_with_timeout, parse_url, url_href, url_origin
|
|
12
|
+
from if_cli.oauth import get_valid_token
|
|
13
|
+
from if_cli.runtime import die, is_record, set_exit_code
|
|
14
|
+
|
|
15
|
+
HTTP_METHODS: tuple[str, ...] = ("get", "post", "put", "patch", "delete", "options", "head", "trace")
|
|
16
|
+
REQUEST_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"}
|
|
17
|
+
WINDOWS_DRIVE_PATH = re.compile(r"^[A-Za-z]:[\\/]")
|
|
18
|
+
MSYS_PREFIX = re.compile(r"^[A-Za-z]:.*?(?=[\\/]api(?:[\\/]|$))")
|
|
19
|
+
|
|
20
|
+
API_OPTIONS: Options = {
|
|
21
|
+
"profile": {"type": "string", "short": "p"},
|
|
22
|
+
"method": {"type": "string", "short": "X"},
|
|
23
|
+
"data": {"type": "string", "short": "d"},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
API_USAGE = (
|
|
27
|
+
"usage: if-cli api [-p profile] [-X METHOD] [-d DATA] /api/path\n"
|
|
28
|
+
" if-cli api routes [-p profile] [filter]\n"
|
|
29
|
+
" if-cli api describe [-p profile] METHOD /api/path"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def discover_routes(document: dict[str, Any], filter_text: str | None = None) -> list[str]:
|
|
34
|
+
normalized_filter = filter_text.lower() if filter_text else None
|
|
35
|
+
routes: list[str] = []
|
|
36
|
+
paths = document.get("paths") or {}
|
|
37
|
+
for path, path_item in paths.items():
|
|
38
|
+
if not isinstance(path_item, dict):
|
|
39
|
+
continue
|
|
40
|
+
for method in HTTP_METHODS:
|
|
41
|
+
operation = path_item.get(method)
|
|
42
|
+
if not operation:
|
|
43
|
+
continue
|
|
44
|
+
summary = operation.get("summary") if isinstance(operation, dict) else None
|
|
45
|
+
line = f"{method.upper():<7} {path}"
|
|
46
|
+
if summary:
|
|
47
|
+
line += f" {summary}"
|
|
48
|
+
if not normalized_filter or normalized_filter in line.lower():
|
|
49
|
+
routes.append(line)
|
|
50
|
+
return sorted(routes)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def describe_route(document: dict[str, Any], method: str, path: str) -> dict[str, Any] | None:
|
|
54
|
+
normalized_method = method.lower()
|
|
55
|
+
if normalized_method not in HTTP_METHODS:
|
|
56
|
+
return None
|
|
57
|
+
path_item = (document.get("paths") or {}).get(path)
|
|
58
|
+
if not isinstance(path_item, dict) or normalized_method not in path_item:
|
|
59
|
+
return None
|
|
60
|
+
operation = path_item[normalized_method]
|
|
61
|
+
return operation if isinstance(operation, dict) else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _die_on_msys_mangled_path(api_path: str) -> None:
|
|
65
|
+
if not WINDOWS_DRIVE_PATH.search(api_path):
|
|
66
|
+
return
|
|
67
|
+
recovered = MSYS_PREFIX.sub("", api_path).replace("\\", "/")
|
|
68
|
+
intended = f" The intended path looks like '{recovered}'." if recovered.startswith("/") else ""
|
|
69
|
+
die(
|
|
70
|
+
f"the API path '{api_path}' is a Windows filesystem path, not an API path.{intended}\n"
|
|
71
|
+
" On Git Bash, MSYS path conversion rewrites leading-slash arguments this way;\n"
|
|
72
|
+
" prefix the command with MSYS_NO_PATHCONV=1."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_absolute_http_url(api_path: str) -> bool:
|
|
77
|
+
parsed = parse_url(api_path)
|
|
78
|
+
return parsed.scheme in {"http", "https"} and bool(parsed.hostname)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_api_url(host: str, api_path: str) -> ParseResult:
|
|
82
|
+
if not api_path.startswith("/") and not _is_absolute_http_url(api_path):
|
|
83
|
+
_die_on_msys_mangled_path(api_path)
|
|
84
|
+
die("API path must begin with '/' or be an absolute http(s) URL on the selected factory origin")
|
|
85
|
+
profile_parsed = parse_url(host)
|
|
86
|
+
url = parse_url(api_path, host)
|
|
87
|
+
profile_origin = url_origin(profile_parsed)
|
|
88
|
+
resolved_origin = url_origin(url)
|
|
89
|
+
if resolved_origin != profile_origin:
|
|
90
|
+
die(f"refusing to send the profile token to {resolved_origin}; selected factory origin is {profile_origin}")
|
|
91
|
+
if url.username or url.password:
|
|
92
|
+
die("API path must not embed credentials; the profile token authenticates the request")
|
|
93
|
+
return url
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _fetch_openapi(host: str) -> dict[str, Any]:
|
|
97
|
+
url = f"{host}/swagger/v1/swagger.json"
|
|
98
|
+
response = fetch_with_timeout(url)
|
|
99
|
+
if not response.ok:
|
|
100
|
+
die(f"API discovery failed for {host} (HTTP {response.status})")
|
|
101
|
+
try:
|
|
102
|
+
body: object = response.json()
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
die(f"API discovery for {host} did not return valid JSON")
|
|
105
|
+
if not is_record(body):
|
|
106
|
+
die(f"API discovery for {host} has no paths")
|
|
107
|
+
if not is_record(body.get("paths")):
|
|
108
|
+
die(f"API discovery for {host} has no paths")
|
|
109
|
+
return body
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _routes_command(positionals: list[str], profile_name: str | None) -> None:
|
|
113
|
+
profile = get_profile(load_config(), resolve_profile_name(profile_name))
|
|
114
|
+
filter_text = positionals[1] if len(positionals) > 1 else None
|
|
115
|
+
routes = discover_routes(_fetch_openapi(profile["host"]), filter_text)
|
|
116
|
+
if not routes:
|
|
117
|
+
die(f"no API routes matched '{filter_text or ''}'")
|
|
118
|
+
sys.stdout.write(f"{chr(10).join(routes)}\n")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _describe_command(positionals: list[str], profile_name: str | None) -> None:
|
|
122
|
+
method = positionals[1].upper() if len(positionals) > 1 and positionals[1] else None
|
|
123
|
+
path = positionals[2] if len(positionals) > 2 else None
|
|
124
|
+
if not method or not path:
|
|
125
|
+
die("usage: if-cli api describe [-p profile] METHOD /api/path")
|
|
126
|
+
if not path.startswith("/"):
|
|
127
|
+
_die_on_msys_mangled_path(path)
|
|
128
|
+
die(f"API path must begin with '/'; '{path}' is not a discovery document path")
|
|
129
|
+
profile = get_profile(load_config(), resolve_profile_name(profile_name))
|
|
130
|
+
operation = describe_route(_fetch_openapi(profile["host"]), method, path)
|
|
131
|
+
if not operation:
|
|
132
|
+
die(f"{method} {path} was not found in the API discovery document")
|
|
133
|
+
sys.stdout.write(f"{json.dumps({'method': method, 'path': path, **operation}, indent=2)}\n")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def api_command(argv: list[str]) -> None:
|
|
137
|
+
values, positionals = parse_args(argv, options=API_OPTIONS, allow_positionals=True)
|
|
138
|
+
if positionals and positionals[0] == "routes":
|
|
139
|
+
_routes_command(positionals, values["profile"])
|
|
140
|
+
return
|
|
141
|
+
if positionals and positionals[0] == "describe":
|
|
142
|
+
_describe_command(positionals, values["profile"])
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
api_path = positionals[0] if positionals else None
|
|
146
|
+
if not api_path:
|
|
147
|
+
die(API_USAGE)
|
|
148
|
+
|
|
149
|
+
if values["method"] is not None:
|
|
150
|
+
method = values["method"].upper()
|
|
151
|
+
else:
|
|
152
|
+
method = "POST" if values["data"] is not None else "GET"
|
|
153
|
+
if method not in REQUEST_METHODS:
|
|
154
|
+
die(f"unsupported request method '{method}'; use GET, POST, PUT, PATCH, DELETE, OPTIONS, or HEAD")
|
|
155
|
+
if values["data"] is not None and method in {"GET", "HEAD"}:
|
|
156
|
+
die("-d cannot be used with GET or HEAD")
|
|
157
|
+
profile = get_profile(load_config(), resolve_profile_name(values["profile"]))
|
|
158
|
+
resolved = resolve_api_url(profile["host"], api_path)
|
|
159
|
+
url = url_href(resolved)
|
|
160
|
+
token = get_valid_token(profile)
|
|
161
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
162
|
+
if values["data"] is not None:
|
|
163
|
+
headers["Content-Type"] = "application/json"
|
|
164
|
+
response = fetch_with_timeout(url, method=method, headers=headers, body=values["data"])
|
|
165
|
+
sys.stderr.write(f"HTTP {response.status}\n")
|
|
166
|
+
body = response.text()
|
|
167
|
+
if body:
|
|
168
|
+
content_type = response.header("content-type") or ""
|
|
169
|
+
if "json" in content_type:
|
|
170
|
+
try:
|
|
171
|
+
sys.stdout.write(f"{json.dumps(json.loads(body), indent=2)}\n")
|
|
172
|
+
except json.JSONDecodeError:
|
|
173
|
+
sys.stdout.write(f"{body}\n")
|
|
174
|
+
else:
|
|
175
|
+
sys.stdout.write(f"{body}\n")
|
|
176
|
+
if response.status >= 400:
|
|
177
|
+
set_exit_code(1)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from if_cli.cli import Options, parse_args
|
|
7
|
+
from if_cli.config import Profile, get_profile, load_config, resolve_profile_name
|
|
8
|
+
from if_cli.runtime import die
|
|
9
|
+
|
|
10
|
+
READABLE_KEYS: dict[str, Callable[[Profile], str | None]] = {
|
|
11
|
+
"host": lambda profile: profile["host"],
|
|
12
|
+
"audience": lambda profile: profile["audience"],
|
|
13
|
+
"callback_port": lambda profile: str(profile["callback_port"]),
|
|
14
|
+
"client_id": lambda profile: profile.get("client_id"),
|
|
15
|
+
"organization": lambda profile: profile.get("organization"),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
CONFIG_USAGE = f"""usage: if-cli config get <key> [-p profile]
|
|
19
|
+
|
|
20
|
+
readable keys: {", ".join(READABLE_KEYS)}"""
|
|
21
|
+
|
|
22
|
+
CONFIG_OPTIONS: Options = {
|
|
23
|
+
"profile": {"type": "string", "short": "p"},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def config_command(argv: list[str]) -> None:
|
|
28
|
+
values, positionals = parse_args(argv, options=CONFIG_OPTIONS, allow_positionals=True)
|
|
29
|
+
subcommand = positionals[0] if positionals else None
|
|
30
|
+
key = positionals[1] if len(positionals) > 1 else None
|
|
31
|
+
extra = positionals[2:]
|
|
32
|
+
if subcommand != "get":
|
|
33
|
+
die(CONFIG_USAGE if subcommand is None else f"unknown config subcommand '{subcommand}'\n\n{CONFIG_USAGE}")
|
|
34
|
+
if key is None:
|
|
35
|
+
die(CONFIG_USAGE)
|
|
36
|
+
if extra:
|
|
37
|
+
die(f"config get takes a single key\n\n{CONFIG_USAGE}")
|
|
38
|
+
if key not in READABLE_KEYS:
|
|
39
|
+
die(f"'{key}' is not a readable config key; readable keys are {', '.join(READABLE_KEYS)}")
|
|
40
|
+
|
|
41
|
+
profile: Profile = get_profile(load_config(), resolve_profile_name(values["profile"]))
|
|
42
|
+
value = READABLE_KEYS[key](profile)
|
|
43
|
+
if value is None:
|
|
44
|
+
die(f"profile '{profile['name']}' has no {key} configured")
|
|
45
|
+
sys.stdout.write(f"{value}\n")
|
if_cli/commands/login.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from if_cli.cli import Options, parse_args
|
|
6
|
+
from if_cli.config import (
|
|
7
|
+
INI_UNSAFE_VALUE,
|
|
8
|
+
empty_section,
|
|
9
|
+
get_profile,
|
|
10
|
+
load_config,
|
|
11
|
+
parse_callback_port,
|
|
12
|
+
resolve_profile_name,
|
|
13
|
+
save_config,
|
|
14
|
+
)
|
|
15
|
+
from if_cli.http import normalize_origin, validate_audience_url
|
|
16
|
+
from if_cli.oauth import login
|
|
17
|
+
from if_cli.runtime import die, format_expiry
|
|
18
|
+
|
|
19
|
+
LOGIN_OPTIONS: Options = {
|
|
20
|
+
"profile": {"type": "string", "short": "p"},
|
|
21
|
+
"host": {"type": "string"},
|
|
22
|
+
"client-id": {"type": "string"},
|
|
23
|
+
"organization": {"type": "string"},
|
|
24
|
+
"audience": {"type": "string"},
|
|
25
|
+
"callback-port": {"type": "string"},
|
|
26
|
+
"no-browser": {"type": "boolean", "default": False},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def login_command(argv: list[str]) -> None:
|
|
31
|
+
values, _positionals = parse_args(argv, options=LOGIN_OPTIONS)
|
|
32
|
+
name = resolve_profile_name(values["profile"])
|
|
33
|
+
has_overrides = any(
|
|
34
|
+
values[key] is not None for key in ("host", "client-id", "organization", "audience", "callback-port")
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def apply_overrides(config: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]:
|
|
38
|
+
key = "" if name == "DEFAULT" else name
|
|
39
|
+
section = config.setdefault(key, empty_section())
|
|
40
|
+
if values["host"] is not None:
|
|
41
|
+
section["host"] = normalize_origin(values["host"], "host")
|
|
42
|
+
if values["client-id"] is not None:
|
|
43
|
+
if not values["client-id"].strip():
|
|
44
|
+
die("client ID cannot be empty")
|
|
45
|
+
if INI_UNSAFE_VALUE.search(values["client-id"]):
|
|
46
|
+
die("--client-id cannot contain newlines or carriage returns")
|
|
47
|
+
section["client_id"] = values["client-id"]
|
|
48
|
+
if values["organization"] is not None:
|
|
49
|
+
if not values["organization"].strip():
|
|
50
|
+
die("organization cannot be empty")
|
|
51
|
+
if INI_UNSAFE_VALUE.search(values["organization"]):
|
|
52
|
+
die("--organization cannot contain newlines or carriage returns")
|
|
53
|
+
section["organization"] = values["organization"]
|
|
54
|
+
if values["audience"] is not None:
|
|
55
|
+
if INI_UNSAFE_VALUE.search(values["audience"]):
|
|
56
|
+
die("--audience cannot contain newlines or carriage returns")
|
|
57
|
+
section["audience"] = validate_audience_url(values["audience"].strip(), "audience")
|
|
58
|
+
if values["callback-port"] is not None:
|
|
59
|
+
section["callback_port"] = str(parse_callback_port(values["callback-port"]))
|
|
60
|
+
return config
|
|
61
|
+
|
|
62
|
+
profile = get_profile(apply_overrides(load_config()) if has_overrides else load_config(), name)
|
|
63
|
+
login_options: dict[str, str] = {}
|
|
64
|
+
if values["client-id"] is not None:
|
|
65
|
+
login_options["client_id_override"] = values["client-id"]
|
|
66
|
+
if values["organization"] is not None:
|
|
67
|
+
login_options["organization_override"] = values["organization"]
|
|
68
|
+
entry = login(profile, bool(values["no-browser"]), login_options)
|
|
69
|
+
if has_overrides:
|
|
70
|
+
save_config(apply_overrides(load_config()))
|
|
71
|
+
refreshable = "with refresh token" if entry.get("refresh_token") else "NO refresh token"
|
|
72
|
+
sys.stdout.write(
|
|
73
|
+
f"✓ logged in to {profile['host']} (profile '{name}', access token valid until "
|
|
74
|
+
f"{format_expiry(int(entry['expires_at']))}, {refreshable})\n"
|
|
75
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from if_cli.cache import clear_token
|
|
6
|
+
from if_cli.cli import Options, parse_args
|
|
7
|
+
from if_cli.config import get_profile, load_config, resolve_profile_name
|
|
8
|
+
|
|
9
|
+
LOGOUT_OPTIONS: Options = {
|
|
10
|
+
"profile": {"type": "string", "short": "p"},
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def logout_command(argv: list[str]) -> None:
|
|
15
|
+
values, _positionals = parse_args(argv, options=LOGOUT_OPTIONS)
|
|
16
|
+
profile = get_profile(load_config(), resolve_profile_name(values["profile"]))
|
|
17
|
+
removed = clear_token(profile["host"])
|
|
18
|
+
sys.stdout.write(
|
|
19
|
+
f"✓ cleared cached token for {profile['host']}\n" if removed else f"no cached token for {profile['host']}\n"
|
|
20
|
+
)
|