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 ADDED
@@ -0,0 +1 @@
1
+ """InsightFactory CLI."""
if_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from if_cli.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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")
@@ -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
+ )