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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from typing import Literal, TypedDict
|
|
7
|
+
|
|
8
|
+
from if_cli.cache import TokenCache, is_fresh, load_cache
|
|
9
|
+
from if_cli.cli import Options, parse_args
|
|
10
|
+
from if_cli.colour import Colour, colourise
|
|
11
|
+
from if_cli.config import config_file, get_profile, load_config
|
|
12
|
+
from if_cli.runtime import format_expiry
|
|
13
|
+
|
|
14
|
+
TokenStatus = Literal["valid", "expired", "none", "unknown"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProfileTokenSummary(TypedDict, total=False):
|
|
18
|
+
status: TokenStatus
|
|
19
|
+
expires_at: float
|
|
20
|
+
refreshable: bool
|
|
21
|
+
error: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProfileSummary(TypedDict, total=False):
|
|
25
|
+
name: str
|
|
26
|
+
host: str | None
|
|
27
|
+
token: ProfileTokenSummary
|
|
28
|
+
error: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
PROFILES_OPTIONS: Options = {
|
|
32
|
+
"json": {"type": "boolean", "default": False},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def summarize_profiles(
|
|
37
|
+
config: dict[str, dict[str, str]],
|
|
38
|
+
cache: TokenCache,
|
|
39
|
+
cache_error: str | None,
|
|
40
|
+
now: float,
|
|
41
|
+
) -> list[ProfileSummary]:
|
|
42
|
+
summaries: list[ProfileSummary] = []
|
|
43
|
+
for section in config:
|
|
44
|
+
name = "DEFAULT" if section == "" else section
|
|
45
|
+
try:
|
|
46
|
+
profile = get_profile(config, name)
|
|
47
|
+
except Exception as error:
|
|
48
|
+
summaries.append(
|
|
49
|
+
{
|
|
50
|
+
"name": name,
|
|
51
|
+
"host": config[section].get("host") or None,
|
|
52
|
+
"token": {"status": "unknown"},
|
|
53
|
+
"error": str(error),
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
entry = cache["tokens"].get(profile["host"])
|
|
59
|
+
token: ProfileTokenSummary
|
|
60
|
+
if cache_error:
|
|
61
|
+
token = {"status": "unknown", "error": cache_error}
|
|
62
|
+
elif not entry:
|
|
63
|
+
token = {"status": "none"}
|
|
64
|
+
else:
|
|
65
|
+
token = {
|
|
66
|
+
"status": "valid" if is_fresh(entry, now) else "expired",
|
|
67
|
+
"expires_at": entry["expires_at"],
|
|
68
|
+
"refreshable": "refresh_token" in entry,
|
|
69
|
+
}
|
|
70
|
+
summaries.append({"name": name, "host": profile["host"], "token": token})
|
|
71
|
+
return summaries
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _describe_token(token: ProfileTokenSummary) -> tuple[str, Colour]:
|
|
75
|
+
status = token["status"]
|
|
76
|
+
if status == "unknown":
|
|
77
|
+
return f"token cache unavailable ({token.get('error')})", "red"
|
|
78
|
+
if status == "none":
|
|
79
|
+
return "no token", "red"
|
|
80
|
+
if status == "valid":
|
|
81
|
+
expiry = "" if token.get("expires_at") is None else f" until {format_expiry(int(token['expires_at']))}"
|
|
82
|
+
refreshable = " (refreshable)" if token.get("refreshable") else ""
|
|
83
|
+
return f"valid{expiry}{refreshable}", "green"
|
|
84
|
+
if status == "expired":
|
|
85
|
+
refreshable = " (refreshable)" if token.get("refreshable") else ""
|
|
86
|
+
return f"expired{refreshable}", "yellow" if token.get("refreshable") else "red"
|
|
87
|
+
return str(status), "red"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def profiles_command(argv: list[str] | None = None) -> None:
|
|
91
|
+
values, _positionals = parse_args(argv or [], options=PROFILES_OPTIONS)
|
|
92
|
+
config = load_config()
|
|
93
|
+
if len(config) == 0:
|
|
94
|
+
if values["json"]:
|
|
95
|
+
sys.stdout.write("[]\n")
|
|
96
|
+
return
|
|
97
|
+
sys.stdout.write(f"no profiles configured in {config_file()}\n")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
cache: TokenCache = {"version": 1, "tokens": {}}
|
|
101
|
+
cache_error: str | None = None
|
|
102
|
+
try:
|
|
103
|
+
cache = load_cache()
|
|
104
|
+
except Exception as error:
|
|
105
|
+
cache_error = str(error)
|
|
106
|
+
|
|
107
|
+
summaries = summarize_profiles(config, cache, cache_error, time.time())
|
|
108
|
+
if values["json"]:
|
|
109
|
+
sys.stdout.write(f"{json.dumps(summaries, indent=2)}\n")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
for summary in summaries:
|
|
113
|
+
if summary.get("error"):
|
|
114
|
+
status = f"misconfigured ({summary['error']})"
|
|
115
|
+
colour = "red"
|
|
116
|
+
else:
|
|
117
|
+
status, colour = _describe_token(summary["token"])
|
|
118
|
+
host = summary.get("host") or "<no host>"
|
|
119
|
+
sys.stdout.write(f"{summary['name']:<20} {host:<55} {colourise(status, colour)}\n")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import math
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from if_cli.cache import store_tokens
|
|
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.runtime import die, format_expiry, jwt_exp
|
|
12
|
+
|
|
13
|
+
SET_TOKEN_OPTIONS: Options = {
|
|
14
|
+
"profile": {"type": "string", "short": "p"},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _read_hidden_token() -> str:
|
|
19
|
+
"""Read a token without echoing it, matching the Node CLI's hidden-input behaviour."""
|
|
20
|
+
try:
|
|
21
|
+
if sys.stdin.isatty():
|
|
22
|
+
return getpass.getpass("").strip()
|
|
23
|
+
line = sys.stdin.readline()
|
|
24
|
+
if line == "":
|
|
25
|
+
die("no token provided")
|
|
26
|
+
return line.strip()
|
|
27
|
+
except KeyboardInterrupt:
|
|
28
|
+
try:
|
|
29
|
+
sys.stderr.write("\n")
|
|
30
|
+
except BrokenPipeError:
|
|
31
|
+
pass
|
|
32
|
+
die("token input cancelled")
|
|
33
|
+
except EOFError:
|
|
34
|
+
die("no token provided")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_token_command(argv: list[str]) -> None:
|
|
38
|
+
values, _positionals = parse_args(argv, options=SET_TOKEN_OPTIONS)
|
|
39
|
+
profile = get_profile(load_config(), resolve_profile_name(values["profile"]))
|
|
40
|
+
sys.stderr.write("Paste the access token (from Profile → Developer → Copy token), then Enter:\n")
|
|
41
|
+
sys.stderr.flush()
|
|
42
|
+
token = _read_hidden_token()
|
|
43
|
+
if not token:
|
|
44
|
+
die("no token provided")
|
|
45
|
+
|
|
46
|
+
expiry = jwt_exp(token)
|
|
47
|
+
now = math.floor(time.time())
|
|
48
|
+
if expiry is not None and expiry <= now:
|
|
49
|
+
die("the provided access token is already expired")
|
|
50
|
+
entry = store_tokens(
|
|
51
|
+
profile["host"],
|
|
52
|
+
{
|
|
53
|
+
"access_token": token,
|
|
54
|
+
"expires_in": 3600 if expiry is None else expiry - now,
|
|
55
|
+
},
|
|
56
|
+
"",
|
|
57
|
+
None,
|
|
58
|
+
replace=True,
|
|
59
|
+
)
|
|
60
|
+
sys.stdout.write(
|
|
61
|
+
f"✓ stored static token for {profile['host']} (expires {format_expiry(int(entry['expires_at']))})\n"
|
|
62
|
+
)
|
if_cli/commands/token.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from if_cli.cli import Options, parse_args
|
|
7
|
+
from if_cli.config import get_profile, load_config, resolve_profile_name
|
|
8
|
+
from if_cli.oauth import get_valid_token
|
|
9
|
+
from if_cli.runtime import die
|
|
10
|
+
|
|
11
|
+
TOKEN_OPTIONS: Options = {
|
|
12
|
+
"profile": {"type": "string", "short": "p"},
|
|
13
|
+
"env": {"type": "boolean", "default": False},
|
|
14
|
+
"env-name": {"type": "string"},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def shell_quote(value: str) -> str:
|
|
19
|
+
return "'" + value.replace("'", "'\\''") + "'"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def token_command(argv: list[str]) -> None:
|
|
23
|
+
values, _positionals = parse_args(argv, options=TOKEN_OPTIONS)
|
|
24
|
+
profile = get_profile(load_config(), resolve_profile_name(values["profile"]))
|
|
25
|
+
environment_name = values["env-name"] or "INSIGHTFACTORY_ACCESS_TOKEN"
|
|
26
|
+
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", environment_name):
|
|
27
|
+
die(f"environment variable name '{environment_name}' is invalid")
|
|
28
|
+
token = get_valid_token(profile)
|
|
29
|
+
if values["env"] or values["env-name"]:
|
|
30
|
+
sys.stdout.write(f"export {environment_name}={shell_quote(token)}\n")
|
|
31
|
+
else:
|
|
32
|
+
sys.stdout.write(f"{token}\n")
|
if_cli/config.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import TypedDict
|
|
8
|
+
|
|
9
|
+
from if_cli.constants import DEFAULT_CALLBACK_PORT, ENV_PROFILE
|
|
10
|
+
from if_cli.http import normalize_origin, validate_audience_url
|
|
11
|
+
from if_cli.runtime import die
|
|
12
|
+
|
|
13
|
+
INI_UNSAFE_VALUE = re.compile(r"[\r\n]")
|
|
14
|
+
INI_UNSAFE_SECTION = re.compile(r"[\[\]\r\n]")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Profile(TypedDict):
|
|
18
|
+
name: str
|
|
19
|
+
host: str
|
|
20
|
+
audience: str
|
|
21
|
+
callback_port: int
|
|
22
|
+
client_id: str | None
|
|
23
|
+
organization: str | None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def config_dir() -> str:
|
|
27
|
+
if "INSIGHTFACTORY_CONFIG_DIR" in os.environ:
|
|
28
|
+
return os.environ["INSIGHTFACTORY_CONFIG_DIR"]
|
|
29
|
+
return os.path.join(os.path.expanduser("~"), ".insightfactory")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def config_file() -> str:
|
|
33
|
+
return os.path.join(config_dir(), "config")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cache_file() -> str:
|
|
37
|
+
return os.path.join(config_dir(), "token-cache.json")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def empty_section() -> dict[str, str]:
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_config() -> dict[str, dict[str, str]]:
|
|
45
|
+
config: dict[str, dict[str, str]] = {}
|
|
46
|
+
try:
|
|
47
|
+
with open(config_file(), encoding="utf-8") as handle:
|
|
48
|
+
raw = handle.read()
|
|
49
|
+
except OSError:
|
|
50
|
+
return config
|
|
51
|
+
|
|
52
|
+
section = ""
|
|
53
|
+
for line in raw.split("\n"):
|
|
54
|
+
trimmed = line.strip()
|
|
55
|
+
if not trimmed or trimmed.startswith("#") or trimmed.startswith(";"):
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
header = re.match(r"^\[(.+)\]$", trimmed)
|
|
59
|
+
if header:
|
|
60
|
+
section_name = header.group(1)
|
|
61
|
+
section = "" if section_name == "DEFAULT" else section_name
|
|
62
|
+
config.setdefault(section, empty_section())
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
equals_index = trimmed.find("=")
|
|
66
|
+
if equals_index > 0:
|
|
67
|
+
config.setdefault(section, empty_section())[trimmed[:equals_index].strip()] = trimmed[
|
|
68
|
+
equals_index + 1 :
|
|
69
|
+
].strip()
|
|
70
|
+
|
|
71
|
+
return config
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save_config(config: dict[str, dict[str, str]]) -> None:
|
|
75
|
+
lines = ["# Managed by if-cli; comments are not preserved when this file is updated.", ""]
|
|
76
|
+
for section, values in config.items():
|
|
77
|
+
profile_name = "DEFAULT" if section == "" else section
|
|
78
|
+
if len(values) == 0:
|
|
79
|
+
continue
|
|
80
|
+
if INI_UNSAFE_SECTION.search(section):
|
|
81
|
+
die(
|
|
82
|
+
f"cannot save profile {json.dumps(profile_name)}: its section name contains [, ], a newline, "
|
|
83
|
+
f"or a carriage return; fix this section in the config file at {config_file()}"
|
|
84
|
+
)
|
|
85
|
+
lines.append(f"[{profile_name}]")
|
|
86
|
+
for key, value in values.items():
|
|
87
|
+
if INI_UNSAFE_VALUE.search(key) or INI_UNSAFE_VALUE.search(value):
|
|
88
|
+
die(
|
|
89
|
+
f"cannot save profile {json.dumps(profile_name)} key {json.dumps(key)}: keys and values "
|
|
90
|
+
f"cannot contain newlines or carriage returns; fix this entry in the config file at {config_file()}"
|
|
91
|
+
)
|
|
92
|
+
lines.append(f"{key} = {value}")
|
|
93
|
+
lines.append("")
|
|
94
|
+
write_private_file(config_file(), "\n".join(lines))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write_private_file(file: str, contents: str) -> None:
|
|
98
|
+
directory = os.path.dirname(file)
|
|
99
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
100
|
+
os.chmod(directory, 0o700)
|
|
101
|
+
temporary_file = f"{file}.{os.getpid()}.{uuid.uuid4()}.tmp"
|
|
102
|
+
descriptor: int | None = None
|
|
103
|
+
try:
|
|
104
|
+
descriptor = os.open(temporary_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
105
|
+
try:
|
|
106
|
+
os.write(descriptor, contents.encode("utf-8"))
|
|
107
|
+
os.fchmod(descriptor, 0o600)
|
|
108
|
+
os.fsync(descriptor)
|
|
109
|
+
finally:
|
|
110
|
+
os.close(descriptor)
|
|
111
|
+
descriptor = None
|
|
112
|
+
os.replace(temporary_file, file)
|
|
113
|
+
os.chmod(file, 0o600)
|
|
114
|
+
directory_descriptor: int | None = None
|
|
115
|
+
try:
|
|
116
|
+
directory_descriptor = os.open(directory, os.O_RDONLY)
|
|
117
|
+
os.fsync(directory_descriptor)
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
finally:
|
|
121
|
+
if directory_descriptor is not None:
|
|
122
|
+
os.close(directory_descriptor)
|
|
123
|
+
finally:
|
|
124
|
+
if descriptor is not None:
|
|
125
|
+
try:
|
|
126
|
+
os.close(descriptor)
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
try:
|
|
130
|
+
os.unlink(temporary_file)
|
|
131
|
+
except OSError:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def meaningful(value: str | None) -> str | None:
|
|
136
|
+
return value if value and value.strip() else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def parse_callback_port(value: str | None) -> int:
|
|
140
|
+
if value is None:
|
|
141
|
+
return DEFAULT_CALLBACK_PORT
|
|
142
|
+
try:
|
|
143
|
+
port = float(value)
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
die(f"callback port must be an integer between 1 and 65535 (received '{value}')")
|
|
146
|
+
if port != int(port) or int(port) < 1 or int(port) > 65_535:
|
|
147
|
+
die(f"callback port must be an integer between 1 and 65535 (received '{value}')")
|
|
148
|
+
return int(port)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def resolve_profile_name(flag: str | None = None) -> str:
|
|
152
|
+
if flag is not None:
|
|
153
|
+
name = flag
|
|
154
|
+
elif ENV_PROFILE in os.environ:
|
|
155
|
+
name = os.environ[ENV_PROFILE]
|
|
156
|
+
else:
|
|
157
|
+
name = "DEFAULT"
|
|
158
|
+
if name == "" or name != name.strip() or re.search(r"[\[\]=\r\n]", name):
|
|
159
|
+
die(
|
|
160
|
+
f"invalid profile name {json.dumps(name)}: names must be non-empty, have no leading or trailing "
|
|
161
|
+
"whitespace, and must not contain [, ], =, newlines, or carriage returns"
|
|
162
|
+
)
|
|
163
|
+
return name
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_profile(config: dict[str, dict[str, str]], name: str) -> Profile:
|
|
167
|
+
section_key = "" if name == "DEFAULT" else name
|
|
168
|
+
if section_key not in config:
|
|
169
|
+
die(f"profile '{name}' not found in {config_file()} — run: if-cli login -p {name} --host <factory-url>")
|
|
170
|
+
section = config[section_key]
|
|
171
|
+
|
|
172
|
+
host = section.get("host")
|
|
173
|
+
if not host:
|
|
174
|
+
die(f"profile '{name}' has no host configured")
|
|
175
|
+
normalized_host = normalize_origin(host, f"host for profile '{name}'")
|
|
176
|
+
audience_value = section.get("audience")
|
|
177
|
+
return {
|
|
178
|
+
"name": name,
|
|
179
|
+
"host": normalized_host,
|
|
180
|
+
"client_id": meaningful(section.get("client_id")),
|
|
181
|
+
"organization": meaningful(section.get("organization")),
|
|
182
|
+
"audience": (
|
|
183
|
+
validate_audience_url(audience_value, f"audience for profile '{name}'")
|
|
184
|
+
if audience_value
|
|
185
|
+
else f"{normalized_host}/api"
|
|
186
|
+
),
|
|
187
|
+
"callback_port": parse_callback_port(section.get("callback_port")),
|
|
188
|
+
}
|
if_cli/constants.py
ADDED
if_cli/http.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from email.message import Message
|
|
8
|
+
from urllib.parse import ParseResult, urljoin, urlparse
|
|
9
|
+
|
|
10
|
+
from if_cli.runtime import die
|
|
11
|
+
|
|
12
|
+
REQUEST_TIMEOUT_MS = 30_000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _with_backslashes_as_slashes(value: str) -> str:
|
|
16
|
+
return value.replace("\\", "/")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_url(value: str, base: str | None = None) -> ParseResult:
|
|
20
|
+
normalized = _with_backslashes_as_slashes(value)
|
|
21
|
+
if base is not None:
|
|
22
|
+
return urlparse(urljoin(_with_backslashes_as_slashes(base), normalized))
|
|
23
|
+
return urlparse(normalized)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _host_for_url(hostname: str) -> str:
|
|
27
|
+
return f"[{hostname}]" if ":" in hostname else hostname
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def url_origin(parsed: ParseResult) -> str:
|
|
31
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
32
|
+
return "null"
|
|
33
|
+
host = _host_for_url(parsed.hostname)
|
|
34
|
+
port = parsed.port
|
|
35
|
+
default_port = 443 if parsed.scheme == "https" else 80
|
|
36
|
+
if port is not None and port != default_port:
|
|
37
|
+
return f"{parsed.scheme}://{host}:{port}"
|
|
38
|
+
return f"{parsed.scheme}://{host}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def url_href(parsed: ParseResult) -> str:
|
|
42
|
+
scheme = parsed.scheme.lower()
|
|
43
|
+
hostname = parsed.hostname
|
|
44
|
+
if not hostname:
|
|
45
|
+
raise ValueError("URL has no hostname")
|
|
46
|
+
host = _host_for_url(hostname)
|
|
47
|
+
userinfo = ""
|
|
48
|
+
if parsed.username:
|
|
49
|
+
userinfo = parsed.username
|
|
50
|
+
if parsed.password is not None:
|
|
51
|
+
userinfo += f":{parsed.password}"
|
|
52
|
+
userinfo += "@"
|
|
53
|
+
port = parsed.port
|
|
54
|
+
default_port = 443 if scheme == "https" else 80 if scheme == "http" else None
|
|
55
|
+
port_part = f":{port}" if port is not None and port != default_port else ""
|
|
56
|
+
path = parsed.path if parsed.path else "/"
|
|
57
|
+
query = f"?{parsed.query}" if parsed.query else ""
|
|
58
|
+
fragment = f"#{parsed.fragment}" if parsed.fragment else ""
|
|
59
|
+
return f"{scheme}://{userinfo}{host}{port_part}{path}{query}{fragment}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_loopback(hostname: str | None) -> bool:
|
|
63
|
+
if hostname is None:
|
|
64
|
+
return False
|
|
65
|
+
return hostname in {"localhost", "127.0.0.1", "::1", "[::1]"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_http_url(value: str, label: str) -> ParseResult:
|
|
69
|
+
try:
|
|
70
|
+
parsed = parse_url(value)
|
|
71
|
+
except ValueError:
|
|
72
|
+
die(f"{label} must be a valid URL")
|
|
73
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
74
|
+
die(f"{label} must be a valid URL")
|
|
75
|
+
loopback = _is_loopback(parsed.hostname)
|
|
76
|
+
if parsed.scheme != "https" and not (parsed.scheme == "http" and loopback):
|
|
77
|
+
die(f"{label} must use HTTPS (HTTP is allowed only for localhost)")
|
|
78
|
+
return parsed
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_http_url(value: str, label: str) -> str:
|
|
82
|
+
return url_href(parse_http_url(value, label))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_audience_url(value: str, label: str) -> str:
|
|
86
|
+
parse_http_url(value, label)
|
|
87
|
+
if any(ord(character) <= 0x20 or ord(character) == 0x7F for character in value):
|
|
88
|
+
die(f"{label} must not contain ASCII control characters or spaces")
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def normalize_origin(value: str, label: str) -> str:
|
|
93
|
+
parsed = parse_http_url(value, label)
|
|
94
|
+
path = parsed.path if parsed.path else "/"
|
|
95
|
+
if parsed.username or parsed.password or path != "/" or parsed.query or parsed.fragment:
|
|
96
|
+
die(f"{label} must be a bare origin without credentials, a path, query, or fragment")
|
|
97
|
+
return url_origin(parsed)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class HttpResponse:
|
|
101
|
+
def __init__(self, status: int, headers: Message | None, body: bytes) -> None:
|
|
102
|
+
self.status = status
|
|
103
|
+
self.headers = headers
|
|
104
|
+
self._body = body
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def ok(self) -> bool:
|
|
108
|
+
return 200 <= self.status < 300
|
|
109
|
+
|
|
110
|
+
def text(self) -> str:
|
|
111
|
+
return self._body.decode("utf-8", errors="replace")
|
|
112
|
+
|
|
113
|
+
def json(self) -> object:
|
|
114
|
+
return json.loads(self.text())
|
|
115
|
+
|
|
116
|
+
def header(self, name: str) -> str | None:
|
|
117
|
+
if self.headers is None:
|
|
118
|
+
return None
|
|
119
|
+
value = self.headers.get(name)
|
|
120
|
+
if value is None:
|
|
121
|
+
return None
|
|
122
|
+
return str(value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def fetch_with_timeout(
|
|
126
|
+
url: str,
|
|
127
|
+
*,
|
|
128
|
+
method: str = "GET",
|
|
129
|
+
headers: dict[str, str] | None = None,
|
|
130
|
+
body: str | bytes | None = None,
|
|
131
|
+
) -> HttpResponse:
|
|
132
|
+
data: bytes | None
|
|
133
|
+
if body is None:
|
|
134
|
+
data = None
|
|
135
|
+
elif isinstance(body, bytes):
|
|
136
|
+
data = body
|
|
137
|
+
else:
|
|
138
|
+
data = body.encode("utf-8")
|
|
139
|
+
request = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
|
|
140
|
+
try:
|
|
141
|
+
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_MS / 1000) as response:
|
|
142
|
+
return HttpResponse(response.status, response.headers, response.read())
|
|
143
|
+
except urllib.error.HTTPError as error:
|
|
144
|
+
return HttpResponse(error.code, error.headers, error.read())
|
|
145
|
+
except urllib.error.URLError as error:
|
|
146
|
+
reason = error.reason
|
|
147
|
+
if isinstance(reason, (TimeoutError, socket.timeout)) or getattr(reason, "errno", None) == getattr(
|
|
148
|
+
socket, "ETIMEDOUT", None
|
|
149
|
+
):
|
|
150
|
+
die(f"request timed out after {REQUEST_TIMEOUT_MS / 1000}s: {url}")
|
|
151
|
+
raise
|
|
152
|
+
except TimeoutError:
|
|
153
|
+
die(f"request timed out after {REQUEST_TIMEOUT_MS / 1000}s: {url}")
|
if_cli/main.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from if_cli.cli import run_with_handlers
|
|
6
|
+
from if_cli.commands.api import api_command
|
|
7
|
+
from if_cli.commands.config import config_command
|
|
8
|
+
from if_cli.commands.login import login_command
|
|
9
|
+
from if_cli.commands.logout import logout_command
|
|
10
|
+
from if_cli.commands.profiles import profiles_command
|
|
11
|
+
from if_cli.commands.set_token import set_token_command
|
|
12
|
+
from if_cli.commands.token import token_command
|
|
13
|
+
from if_cli.constants import ENV_PROFILE
|
|
14
|
+
from if_cli.runtime import die
|
|
15
|
+
|
|
16
|
+
USAGE = f"""usage: if-cli <command> [options]
|
|
17
|
+
|
|
18
|
+
commands:
|
|
19
|
+
login browser OAuth login (auth-code + PKCE via the factory /authorize proxy)
|
|
20
|
+
[-p profile] [--host url] [--client-id id] [--organization org]
|
|
21
|
+
[--audience aud] [--callback-port n] [--no-browser]
|
|
22
|
+
token print a valid access token (auto-refreshes)
|
|
23
|
+
[-p profile] [--env] [--env-name VARIABLE]
|
|
24
|
+
logout clear the cached token for a profile [-p profile]
|
|
25
|
+
set-token store a manually copied token for a profile (fallback) [-p profile]
|
|
26
|
+
profiles list profiles and token status [--json]
|
|
27
|
+
config print a non-secret profile value
|
|
28
|
+
get <host|audience|callback_port|client_id|organization> [-p profile]
|
|
29
|
+
api call or discover factory API routes
|
|
30
|
+
[-p profile] [-X METHOD] [-d DATA] <path>
|
|
31
|
+
routes [-p profile] [filter]
|
|
32
|
+
describe [-p profile] METHOD <path>
|
|
33
|
+
|
|
34
|
+
profile selection: -p flag, then ${ENV_PROFILE}, then [DEFAULT].
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_cli(argv: list[str]) -> None:
|
|
39
|
+
command = argv[0] if argv else None
|
|
40
|
+
rest = argv[1:]
|
|
41
|
+
if command == "login":
|
|
42
|
+
login_command(rest)
|
|
43
|
+
return
|
|
44
|
+
if command == "token":
|
|
45
|
+
token_command(rest)
|
|
46
|
+
return
|
|
47
|
+
if command == "logout":
|
|
48
|
+
logout_command(rest)
|
|
49
|
+
return
|
|
50
|
+
if command == "set-token":
|
|
51
|
+
set_token_command(rest)
|
|
52
|
+
return
|
|
53
|
+
if command == "profiles":
|
|
54
|
+
profiles_command(rest)
|
|
55
|
+
return
|
|
56
|
+
if command == "config":
|
|
57
|
+
config_command(rest)
|
|
58
|
+
return
|
|
59
|
+
if command == "api":
|
|
60
|
+
api_command(rest)
|
|
61
|
+
return
|
|
62
|
+
if command in {None, "-h", "--help"}:
|
|
63
|
+
import sys
|
|
64
|
+
|
|
65
|
+
sys.stdout.write(USAGE)
|
|
66
|
+
return
|
|
67
|
+
if command in {"-v", "--version"}:
|
|
68
|
+
import sys
|
|
69
|
+
|
|
70
|
+
sys.stdout.write(f"{version('insightfactory-cli')}\n")
|
|
71
|
+
return
|
|
72
|
+
die(f"unknown command '{command}'\n\n{USAGE}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: list[str] | None = None) -> None:
|
|
76
|
+
run_with_handlers(run_cli, argv)
|