insightfactory-cli 1.2.0.dev34__py3-none-any.whl → 1.3.0.dev39__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/cache.py +133 -21
- if_cli/commands/logout.py +3 -4
- if_cli/commands/profiles.py +3 -3
- if_cli/commands/set_token.py +1 -1
- if_cli/oauth.py +13 -14
- {insightfactory_cli-1.2.0.dev34.dist-info → insightfactory_cli-1.3.0.dev39.dist-info}/METADATA +24 -9
- {insightfactory_cli-1.2.0.dev34.dist-info → insightfactory_cli-1.3.0.dev39.dist-info}/RECORD +10 -10
- {insightfactory_cli-1.2.0.dev34.dist-info → insightfactory_cli-1.3.0.dev39.dist-info}/WHEEL +0 -0
- {insightfactory_cli-1.2.0.dev34.dist-info → insightfactory_cli-1.3.0.dev39.dist-info}/entry_points.txt +0 -0
- {insightfactory_cli-1.2.0.dev34.dist-info → insightfactory_cli-1.3.0.dev39.dist-info}/licenses/LICENSE +0 -0
if_cli/cache.py
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import json
|
|
4
5
|
import math
|
|
5
6
|
import os
|
|
6
7
|
import time
|
|
7
8
|
from collections.abc import Callable
|
|
8
|
-
from typing import TypedDict, TypeGuard, TypeVar
|
|
9
|
+
from typing import NoReturn, TypedDict, TypeGuard, TypeVar
|
|
9
10
|
|
|
10
|
-
from if_cli.config import cache_file, write_private_file
|
|
11
|
+
from if_cli.config import cache_file, get_profile, is_factory_section, load_config, write_private_file
|
|
11
12
|
from if_cli.constants import EXPIRY_SLACK_SECONDS
|
|
12
13
|
from if_cli.runtime import die, is_record
|
|
13
14
|
|
|
14
15
|
CACHE_LOCK_STALE_MS = 30_000
|
|
16
|
+
CACHE_VERSION = 2
|
|
17
|
+
HOST_KEYED_CACHE_VERSION = 1
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
class CacheEntry(TypedDict, total=False):
|
|
21
|
+
host: str
|
|
22
|
+
grant: str
|
|
18
23
|
access_token: str
|
|
19
24
|
refresh_token: str
|
|
20
25
|
expires_at: float
|
|
@@ -23,10 +28,53 @@ class CacheEntry(TypedDict, total=False):
|
|
|
23
28
|
|
|
24
29
|
|
|
25
30
|
class TokenCache(TypedDict):
|
|
31
|
+
"""Version 2 keys `tokens` by profile name. Each entry records the host it was
|
|
32
|
+
issued for, and is used only while the profile still points at that host."""
|
|
33
|
+
|
|
26
34
|
version: int
|
|
27
35
|
tokens: dict[str, CacheEntry]
|
|
28
36
|
|
|
29
37
|
|
|
38
|
+
class TokenOwner(TypedDict):
|
|
39
|
+
"""The part of a `Profile` that owns a cache entry."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
host: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def empty_cache() -> TokenCache:
|
|
46
|
+
return {"version": CACHE_VERSION, "tokens": {}}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def entry_for(cache: TokenCache, owner: TokenOwner) -> CacheEntry | None:
|
|
50
|
+
"""The profile's entry, unless it was issued for a host the profile no longer
|
|
51
|
+
names: a bearer token never goes to an origin other than the one it came from."""
|
|
52
|
+
entry = cache["tokens"].get(owner["name"])
|
|
53
|
+
if entry is None or entry.get("host") != owner["host"]:
|
|
54
|
+
return None
|
|
55
|
+
return entry
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _siblings(cache: TokenCache, owner: TokenOwner, grant: str | None) -> list[str]:
|
|
59
|
+
"""Other profiles on the same host that share the owner's `grant`.
|
|
60
|
+
|
|
61
|
+
Only a version 1 cache produces these: it held one entry per host, which the
|
|
62
|
+
upgrade copies to every profile on that host, marking the copies with one
|
|
63
|
+
`grant` id. They are one sign-in, so a rotation or rejection on one applies to
|
|
64
|
+
all of them; leaving a sibling on the rotated-out refresh token would replay it
|
|
65
|
+
on its next refresh. The id outlives a cleared refresh token, so a refresh that
|
|
66
|
+
lands after a peer's invalid_grant still reaches every sibling. A login or a
|
|
67
|
+
pasted token starts a fresh entry without it, which ends the sharing.
|
|
68
|
+
"""
|
|
69
|
+
if not grant:
|
|
70
|
+
return []
|
|
71
|
+
return [
|
|
72
|
+
name
|
|
73
|
+
for name, entry in cache["tokens"].items()
|
|
74
|
+
if name != owner["name"] and entry.get("host") == owner["host"] and entry.get("grant") == grant
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
30
78
|
def _positive_integer_env(name: str, fallback: int) -> int:
|
|
31
79
|
raw = os.environ.get(name)
|
|
32
80
|
if raw is None:
|
|
@@ -58,9 +106,13 @@ def is_fresh(entry: CacheEntry, now_seconds: float | None = None) -> bool:
|
|
|
58
106
|
return entry["expires_at"] - EXPIRY_SLACK_SECONDS > now_seconds
|
|
59
107
|
|
|
60
108
|
|
|
61
|
-
def _is_cache_entry(value: object) -> TypeGuard[CacheEntry]:
|
|
109
|
+
def _is_cache_entry(value: object, *, with_host: bool) -> TypeGuard[CacheEntry]:
|
|
62
110
|
if not is_record(value):
|
|
63
111
|
return False
|
|
112
|
+
if with_host and not isinstance(value.get("host"), str):
|
|
113
|
+
return False
|
|
114
|
+
if "grant" in value and not isinstance(value.get("grant"), str):
|
|
115
|
+
return False
|
|
64
116
|
access_token = value.get("access_token")
|
|
65
117
|
expires_at = value.get("expires_at")
|
|
66
118
|
token_endpoint = value.get("token_endpoint")
|
|
@@ -77,19 +129,67 @@ def _is_cache_entry(value: object) -> TypeGuard[CacheEntry]:
|
|
|
77
129
|
)
|
|
78
130
|
|
|
79
131
|
|
|
132
|
+
def _invalid_structure() -> NoReturn:
|
|
133
|
+
die(f"token cache {cache_file()} has an invalid structure; move it aside and log in again")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_entries(tokens_value: dict[str, object], *, with_host: bool) -> dict[str, CacheEntry]:
|
|
137
|
+
tokens: dict[str, CacheEntry] = {}
|
|
138
|
+
for key, entry in tokens_value.items():
|
|
139
|
+
if not _is_cache_entry(entry, with_host=with_host):
|
|
140
|
+
die(f"token cache entry for {key} is invalid; move {cache_file()} aside and log in again")
|
|
141
|
+
tokens[key] = entry
|
|
142
|
+
return tokens
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _from_host_keyed(tokens_by_host: dict[str, CacheEntry]) -> dict[str, CacheEntry]:
|
|
146
|
+
"""Give each configured profile the version 1 entry for its host.
|
|
147
|
+
|
|
148
|
+
A host entry with no profile left on that host is unreachable, so it is
|
|
149
|
+
dropped. A profile that does not resolve gets nothing, as it could not have
|
|
150
|
+
used a token before either.
|
|
151
|
+
"""
|
|
152
|
+
config = load_config()
|
|
153
|
+
tokens: dict[str, CacheEntry] = {}
|
|
154
|
+
for section in config:
|
|
155
|
+
if is_factory_section(section):
|
|
156
|
+
continue
|
|
157
|
+
name = "DEFAULT" if section == "" else section
|
|
158
|
+
try:
|
|
159
|
+
host = get_profile(config, name)["host"]
|
|
160
|
+
except Exception:
|
|
161
|
+
# Not only CliError: a bad port raises ValueError or OverflowError. One
|
|
162
|
+
# broken profile must not stop every other profile's cache read.
|
|
163
|
+
continue
|
|
164
|
+
entry = tokens_by_host.get(host)
|
|
165
|
+
if entry is not None:
|
|
166
|
+
migrated = entry.copy()
|
|
167
|
+
migrated["host"] = host
|
|
168
|
+
tokens[name] = migrated
|
|
169
|
+
hosts = [entry["host"] for entry in tokens.values()]
|
|
170
|
+
for entry in tokens.values():
|
|
171
|
+
refresh_token = entry.get("refresh_token")
|
|
172
|
+
if refresh_token and hosts.count(entry["host"]) > 1:
|
|
173
|
+
# Derived, not random: every process reading the same version 1 file
|
|
174
|
+
# agrees on it until the first write saves version 2.
|
|
175
|
+
entry["grant"] = hashlib.sha256(f"{entry['host']}\n{refresh_token}".encode()).hexdigest()[:32]
|
|
176
|
+
return tokens
|
|
177
|
+
|
|
178
|
+
|
|
80
179
|
def _parse_cache(raw: str) -> TokenCache:
|
|
81
180
|
value: object = json.loads(raw)
|
|
82
181
|
if not is_record(value):
|
|
83
|
-
|
|
182
|
+
_invalid_structure()
|
|
84
183
|
tokens_value = value.get("tokens")
|
|
85
|
-
if
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
184
|
+
if not is_record(tokens_value):
|
|
185
|
+
_invalid_structure()
|
|
186
|
+
version = value.get("version")
|
|
187
|
+
if version == CACHE_VERSION:
|
|
188
|
+
return {"version": CACHE_VERSION, "tokens": _parse_entries(tokens_value, with_host=True)}
|
|
189
|
+
if version == HOST_KEYED_CACHE_VERSION:
|
|
190
|
+
tokens_by_host = _parse_entries(tokens_value, with_host=False)
|
|
191
|
+
return {"version": CACHE_VERSION, "tokens": _from_host_keyed(tokens_by_host)}
|
|
192
|
+
_invalid_structure()
|
|
93
193
|
|
|
94
194
|
|
|
95
195
|
def load_cache() -> TokenCache:
|
|
@@ -97,7 +197,7 @@ def load_cache() -> TokenCache:
|
|
|
97
197
|
with open(cache_file(), encoding="utf-8") as handle:
|
|
98
198
|
return _parse_cache(handle.read())
|
|
99
199
|
except FileNotFoundError:
|
|
100
|
-
return
|
|
200
|
+
return empty_cache()
|
|
101
201
|
except json.JSONDecodeError:
|
|
102
202
|
die(f"token cache {cache_file()} is not valid JSON; move it aside and log in again")
|
|
103
203
|
|
|
@@ -138,24 +238,29 @@ def with_cache_lock(operation: Callable[[], T]) -> T:
|
|
|
138
238
|
die(f"timed out waiting for token cache lock {lock_file}")
|
|
139
239
|
|
|
140
240
|
|
|
141
|
-
def clear_token(
|
|
241
|
+
def clear_token(owner: TokenOwner) -> bool:
|
|
242
|
+
"""Remove the profile's entry, whichever host it was issued for."""
|
|
243
|
+
|
|
142
244
|
def operation() -> bool:
|
|
143
245
|
cache = load_cache()
|
|
144
|
-
if
|
|
246
|
+
if owner["name"] not in cache["tokens"]:
|
|
145
247
|
return False
|
|
146
|
-
del cache["tokens"][
|
|
248
|
+
del cache["tokens"][owner["name"]]
|
|
147
249
|
_save_cache(cache)
|
|
148
250
|
return True
|
|
149
251
|
|
|
150
252
|
return with_cache_lock(operation)
|
|
151
253
|
|
|
152
254
|
|
|
153
|
-
def clear_refresh_token_if_matches(
|
|
255
|
+
def clear_refresh_token_if_matches(owner: TokenOwner, rejected_token: str) -> bool:
|
|
154
256
|
def operation() -> bool:
|
|
155
257
|
cache = load_cache()
|
|
156
|
-
entry = cache
|
|
258
|
+
entry = entry_for(cache, owner)
|
|
157
259
|
if not entry or entry.get("refresh_token") != rejected_token:
|
|
158
260
|
return False
|
|
261
|
+
for name in _siblings(cache, owner, entry.get("grant")):
|
|
262
|
+
if cache["tokens"][name].get("refresh_token") == rejected_token:
|
|
263
|
+
cache["tokens"][name].pop("refresh_token", None)
|
|
159
264
|
entry.pop("refresh_token", None)
|
|
160
265
|
_save_cache(cache)
|
|
161
266
|
return True
|
|
@@ -164,16 +269,19 @@ def clear_refresh_token_if_matches(host: str, rejected_token: str) -> bool:
|
|
|
164
269
|
|
|
165
270
|
|
|
166
271
|
def store_tokens(
|
|
167
|
-
|
|
272
|
+
owner: TokenOwner,
|
|
168
273
|
token_response: dict[str, object],
|
|
169
274
|
token_endpoint: str,
|
|
170
275
|
client_id: str | None = None,
|
|
171
276
|
*,
|
|
172
277
|
replace: bool = False,
|
|
173
278
|
) -> CacheEntry:
|
|
279
|
+
"""Write the profile's entry. `replace` starts it afresh (a login or a pasted
|
|
280
|
+
token); otherwise it updates the entry in place (a refresh)."""
|
|
281
|
+
|
|
174
282
|
def operation() -> CacheEntry:
|
|
175
283
|
cache = load_cache()
|
|
176
|
-
previous = None if replace else cache
|
|
284
|
+
previous = None if replace else entry_for(cache, owner)
|
|
177
285
|
expires_in_raw = token_response.get("expires_in")
|
|
178
286
|
if (
|
|
179
287
|
isinstance(expires_in_raw, bool)
|
|
@@ -187,6 +295,7 @@ def store_tokens(
|
|
|
187
295
|
access_token = token_response.get("access_token")
|
|
188
296
|
if not isinstance(access_token, str):
|
|
189
297
|
die("token response did not include a valid access token")
|
|
298
|
+
entry["host"] = owner["host"]
|
|
190
299
|
entry["access_token"] = access_token
|
|
191
300
|
entry["expires_at"] = int(math.floor(time.time()) + expires_in)
|
|
192
301
|
entry["token_endpoint"] = token_endpoint
|
|
@@ -199,7 +308,10 @@ def store_tokens(
|
|
|
199
308
|
entry["refresh_token"] = refresh_token
|
|
200
309
|
elif replace:
|
|
201
310
|
entry.pop("refresh_token", None)
|
|
202
|
-
|
|
311
|
+
if previous is not None:
|
|
312
|
+
for name in _siblings(cache, owner, previous.get("grant")):
|
|
313
|
+
cache["tokens"][name] = entry.copy()
|
|
314
|
+
cache["tokens"][owner["name"]] = entry
|
|
203
315
|
_save_cache(cache)
|
|
204
316
|
return entry
|
|
205
317
|
|
if_cli/commands/logout.py
CHANGED
|
@@ -14,7 +14,6 @@ LOGOUT_OPTIONS: Options = {
|
|
|
14
14
|
def logout_command(argv: list[str]) -> None:
|
|
15
15
|
values, _positionals = parse_args(argv, options=LOGOUT_OPTIONS)
|
|
16
16
|
profile = get_profile(load_config(), resolve_profile_name(values["profile"]))
|
|
17
|
-
removed = clear_token(profile
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
)
|
|
17
|
+
removed = clear_token(profile)
|
|
18
|
+
target = f"profile '{profile['name']}' ({profile['host']})"
|
|
19
|
+
sys.stdout.write(f"✓ cleared cached token for {target}\n" if removed else f"no cached token for {target}\n")
|
if_cli/commands/profiles.py
CHANGED
|
@@ -5,7 +5,7 @@ import sys
|
|
|
5
5
|
import time
|
|
6
6
|
from typing import Literal, TypedDict
|
|
7
7
|
|
|
8
|
-
from if_cli.cache import TokenCache, is_fresh, load_cache
|
|
8
|
+
from if_cli.cache import TokenCache, empty_cache, entry_for, is_fresh, load_cache
|
|
9
9
|
from if_cli.cli import Options, parse_args
|
|
10
10
|
from if_cli.colour import Colour, colourise
|
|
11
11
|
from if_cli.config import config_file, get_profile, is_factory_section, load_config
|
|
@@ -57,7 +57,7 @@ def summarize_profiles(
|
|
|
57
57
|
)
|
|
58
58
|
continue
|
|
59
59
|
|
|
60
|
-
entry = cache
|
|
60
|
+
entry = entry_for(cache, profile)
|
|
61
61
|
token: ProfileTokenSummary
|
|
62
62
|
if cache_error:
|
|
63
63
|
token = {"status": "unknown", "error": cache_error}
|
|
@@ -99,7 +99,7 @@ def profiles_command(argv: list[str] | None = None) -> None:
|
|
|
99
99
|
sys.stdout.write(f"no profiles configured in {config_file()}\n")
|
|
100
100
|
return
|
|
101
101
|
|
|
102
|
-
cache: TokenCache =
|
|
102
|
+
cache: TokenCache = empty_cache()
|
|
103
103
|
cache_error: str | None = None
|
|
104
104
|
try:
|
|
105
105
|
cache = load_cache()
|
if_cli/commands/set_token.py
CHANGED
|
@@ -48,7 +48,7 @@ def set_token_command(argv: list[str]) -> None:
|
|
|
48
48
|
if expiry is not None and expiry <= now:
|
|
49
49
|
die("the provided access token is already expired")
|
|
50
50
|
entry = store_tokens(
|
|
51
|
-
profile
|
|
51
|
+
profile,
|
|
52
52
|
{
|
|
53
53
|
"access_token": token,
|
|
54
54
|
"expires_in": 3600 if expiry is None else expiry - now,
|
if_cli/oauth.py
CHANGED
|
@@ -14,6 +14,7 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
|
14
14
|
from if_cli.cache import (
|
|
15
15
|
CacheEntry,
|
|
16
16
|
clear_refresh_token_if_matches,
|
|
17
|
+
entry_for,
|
|
17
18
|
is_fresh,
|
|
18
19
|
load_cache,
|
|
19
20
|
store_tokens,
|
|
@@ -334,10 +335,10 @@ def login(profile: Profile, no_browser: bool, options: dict[str, str] | None = N
|
|
|
334
335
|
"redirect_uri": redirect_uri,
|
|
335
336
|
},
|
|
336
337
|
)
|
|
337
|
-
return store_tokens(profile
|
|
338
|
+
return store_tokens(profile, tokens, metadata["token_endpoint"], client_id, replace=True)
|
|
338
339
|
|
|
339
340
|
|
|
340
|
-
def _refresh(
|
|
341
|
+
def _refresh(profile: Profile, entry: CacheEntry, refresh_token: str, client_id: str) -> CacheEntry:
|
|
341
342
|
tokens = _token_request(
|
|
342
343
|
entry["token_endpoint"],
|
|
343
344
|
{
|
|
@@ -346,12 +347,12 @@ def _refresh(host: str, entry: CacheEntry, refresh_token: str, client_id: str) -
|
|
|
346
347
|
"refresh_token": refresh_token,
|
|
347
348
|
},
|
|
348
349
|
)
|
|
349
|
-
return store_tokens(
|
|
350
|
+
return store_tokens(profile, tokens, entry["token_endpoint"], client_id)
|
|
350
351
|
|
|
351
352
|
|
|
352
|
-
def _wait_for_fresh_token(
|
|
353
|
+
def _wait_for_fresh_token(profile: Profile, *, exclude: str | None = None) -> str | None:
|
|
353
354
|
for attempt in range(RECOVERY_POLL_ATTEMPTS):
|
|
354
|
-
entry = load_cache()
|
|
355
|
+
entry = entry_for(load_cache(), profile)
|
|
355
356
|
if entry and is_fresh(entry) and entry["access_token"] != exclude:
|
|
356
357
|
return entry["access_token"]
|
|
357
358
|
if attempt + 1 < RECOVERY_POLL_ATTEMPTS:
|
|
@@ -360,7 +361,7 @@ def _wait_for_fresh_token(host: str, *, exclude: str | None = None) -> str | Non
|
|
|
360
361
|
|
|
361
362
|
|
|
362
363
|
def get_valid_token(profile: Profile, *, force_refresh: bool = False) -> str:
|
|
363
|
-
entry = load_cache()
|
|
364
|
+
entry = entry_for(load_cache(), profile)
|
|
364
365
|
if not entry:
|
|
365
366
|
die(f"no cached token for {profile['host']} — run: if-cli login -p {profile['name']}")
|
|
366
367
|
if not force_refresh and is_fresh(entry):
|
|
@@ -374,32 +375,30 @@ def get_valid_token(profile: Profile, *, force_refresh: bool = False) -> str:
|
|
|
374
375
|
if refresh_token:
|
|
375
376
|
client_id = entry.get("client_id")
|
|
376
377
|
if not client_id:
|
|
377
|
-
clear_refresh_token_if_matches(profile
|
|
378
|
+
clear_refresh_token_if_matches(profile, refresh_token)
|
|
378
379
|
die(
|
|
379
380
|
f"could not refresh token for {profile['host']}: no client id cached — "
|
|
380
381
|
f"run: if-cli login -p {profile['name']}"
|
|
381
382
|
)
|
|
382
383
|
try:
|
|
383
|
-
return _refresh(profile
|
|
384
|
+
return _refresh(profile, entry, refresh_token, client_id)["access_token"]
|
|
384
385
|
except Exception as error:
|
|
385
386
|
failure: BaseException = error
|
|
386
387
|
if isinstance(error, TokenRequestError) and error.invalidates_refresh_token:
|
|
387
|
-
cleared = clear_refresh_token_if_matches(profile
|
|
388
|
+
cleared = clear_refresh_token_if_matches(profile, refresh_token)
|
|
388
389
|
if cleared:
|
|
389
390
|
recovered_access_token = _wait_for_fresh_token(
|
|
390
|
-
profile
|
|
391
|
+
profile, exclude=entry["access_token"] if force_refresh else None
|
|
391
392
|
)
|
|
392
393
|
if recovered_access_token:
|
|
393
394
|
return recovered_access_token
|
|
394
395
|
else:
|
|
395
|
-
recovered = load_cache()
|
|
396
|
+
recovered = entry_for(load_cache(), profile)
|
|
396
397
|
recovered_refresh = recovered.get("refresh_token") if recovered else None
|
|
397
398
|
recovered_client_id = recovered.get("client_id") if recovered else None
|
|
398
399
|
if recovered and recovered_refresh and recovered_refresh != refresh_token and recovered_client_id:
|
|
399
400
|
try:
|
|
400
|
-
return _refresh(profile
|
|
401
|
-
"access_token"
|
|
402
|
-
]
|
|
401
|
+
return _refresh(profile, recovered, recovered_refresh, recovered_client_id)["access_token"]
|
|
403
402
|
except Exception as retry_error:
|
|
404
403
|
failure = retry_error
|
|
405
404
|
detail = f": {failure}" if isinstance(failure, Exception) else ""
|
{insightfactory_cli-1.2.0.dev34.dist-info → insightfactory_cli-1.3.0.dev39.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: insightfactory-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0.dev39
|
|
4
4
|
Summary: Profile-based authentication CLI for the InsightFactory Interfaces API
|
|
5
5
|
Project-URL: Homepage, https://insightfactory.ai
|
|
6
6
|
Author-email: "insightfactory.ai Support" <support@insightfactory.ai>
|
|
@@ -27,9 +27,9 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
Profile-based authentication CLI for the InsightFactory Interfaces API. It
|
|
28
28
|
installs the `if-cli` command, which logs in through the browser with OAuth
|
|
29
29
|
authorization code and PKCE, refreshes tokens, and makes authenticated API
|
|
30
|
-
calls. Profiles live in `~/.insightfactory` in the same layout the Node
|
|
31
|
-
`@insightfactory-ai/if-cli`
|
|
32
|
-
|
|
30
|
+
calls. Profiles live in `~/.insightfactory` in the same layout the retired Node
|
|
31
|
+
`@insightfactory-ai/if-cli` used. The token cache uses its own format, described
|
|
32
|
+
under "How authentication works".
|
|
33
33
|
|
|
34
34
|
This is the production Python CLI, published to public PyPI. It needs Python
|
|
35
35
|
3.10 or newer.
|
|
@@ -229,8 +229,20 @@ rejected.
|
|
|
229
229
|
- Profiles live in `~/.insightfactory/config`, one per customer and environment.
|
|
230
230
|
- The CLI manages the profile file and regenerates it on updates. Comments and
|
|
231
231
|
empty sections are not preserved.
|
|
232
|
-
- Tokens are cached
|
|
233
|
-
mode `0600`.
|
|
232
|
+
- Tokens are cached per profile in `~/.insightfactory/token-cache.json` with
|
|
233
|
+
file mode `0600`. Two profiles on the same host can sign in as different
|
|
234
|
+
accounts, and each keeps its own token. Each entry records the host that
|
|
235
|
+
issued it, and a profile uses it only while its `host` still matches, so a
|
|
236
|
+
token never goes to another origin after a host change.
|
|
237
|
+
- `if-cli` 1.3.0 and later write version 2 of the cache, keyed by profile
|
|
238
|
+
name. They read the older version 1 cache, keyed by host, and give its token
|
|
239
|
+
to every profile on that host, so an upgrade needs no new login. Older
|
|
240
|
+
`if-cli` releases and the Node CLI cannot read version 2 and stop on every
|
|
241
|
+
command, `login` included. To downgrade, move `token-cache.json` aside, then
|
|
242
|
+
sign in to each profile again.
|
|
243
|
+
- A version 1 cache holds one account per host. If two profiles on one host
|
|
244
|
+
signed in as different accounts before 1.3.0, both start out as whichever
|
|
245
|
+
account signed in last. Run `if-cli login -p <profile>` once for each of them.
|
|
234
246
|
- The configuration directory is `0700`. Configuration and cache updates are
|
|
235
247
|
atomic.
|
|
236
248
|
- Remote factory, authorization, and token endpoints must use HTTPS. HTTP is
|
|
@@ -512,9 +524,12 @@ differ:
|
|
|
512
524
|
- **`--version`** reads the installed package metadata from `1.0.0` onward
|
|
513
525
|
rather than an npm `package.json`.
|
|
514
526
|
|
|
515
|
-
On-disk profile and token-cache paths
|
|
516
|
-
|
|
517
|
-
|
|
527
|
+
On-disk profile and token-cache paths and file permissions match the Node CLI.
|
|
528
|
+
The token cache's JSON shape does not: `if-cli` keys it by profile (version 2),
|
|
529
|
+
which the Node CLI and older `if-cli` releases cannot read. The Python CLI
|
|
530
|
+
still reads a Node-written version 1 cache, so moving from Node to Python needs
|
|
531
|
+
no new login. To move back, move `token-cache.json` aside and sign in to each
|
|
532
|
+
profile again.
|
|
518
533
|
|
|
519
534
|
## Licence
|
|
520
535
|
|
{insightfactory_cli-1.2.0.dev34.dist-info → insightfactory_cli-1.3.0.dev39.dist-info}/RECORD
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
if_cli/__init__.py,sha256=YYcz-hEiXXUjdBFGipOrVHynsyH4ZLqQOCDobvCAu8Y,26
|
|
2
2
|
if_cli/__main__.py,sha256=2hLFIiT-gocMb0VJVzVYtGVIoHrxyMChucRgexbXLSQ,68
|
|
3
3
|
if_cli/attribution.py,sha256=hNp1hBueRUh1EKYs61AZF76voehw70F7gZKiI5zOMCw,1869
|
|
4
|
-
if_cli/cache.py,sha256
|
|
4
|
+
if_cli/cache.py,sha256=-jEr2t50_dRQ7ZBorbOVyqEkwDdWZOgbNoPHAW5yHPU,11240
|
|
5
5
|
if_cli/callback_page.py,sha256=lTHd3-vzlT9ndAL7XYonBfy97lBW7apRw3V7SmhNObo,5907
|
|
6
6
|
if_cli/cli.py,sha256=fF7HvsUgpKhkLFdXLMJ1JXHGjp3RwJt2YmPSEW0d8gY,6270
|
|
7
7
|
if_cli/colour.py,sha256=2hbaM4ubTHGja6YZRD886eqnB7T2IaZyW5rvfOjxe4M,682
|
|
@@ -9,7 +9,7 @@ if_cli/config.py,sha256=QiphIhVLDN3sp39xMWKwXEyJWxx4IhXT28oEtmPc0ew,10601
|
|
|
9
9
|
if_cli/constants.py,sha256=WxZozWfhMCFWU_Wupa__CaJI9UgqJ28W8cchGtp6p4Q,303
|
|
10
10
|
if_cli/http.py,sha256=LNkQtyUR1DAgLcR2CrNzTMHFQO_j_vfHuOBagmQEYNk,8249
|
|
11
11
|
if_cli/main.py,sha256=pn-X2wrHNplc8ceT5_nEv5XlrTN3KgpK8UU4gAmBiWY,3212
|
|
12
|
-
if_cli/oauth.py,sha256=
|
|
12
|
+
if_cli/oauth.py,sha256=3ot4a61vrHNjaxBHJZaczFb4_Fz-Vzg8wmcS1P92tQA,16463
|
|
13
13
|
if_cli/runtime.py,sha256=Pz2iVclqy-uu1UMQWK2P_Hx7O4zAF30UpSFqQvGN0NY,2663
|
|
14
14
|
if_cli/assets/__init__.py,sha256=2vBBor8lUEQofiC6EvS5BvF_ANhfK8bfR9S0OXI8j3g,41
|
|
15
15
|
if_cli/assets/insightfactoryai-logo.svg,sha256=yFxHkoHl6VNm8ppCInTZCVN_bdOog9h-NV8R4Gw1Qew,8192
|
|
@@ -17,10 +17,10 @@ if_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
17
17
|
if_cli/commands/api.py,sha256=XeVhhD39Oho4xNbMJIGh1I6vi53on5EGGsAtsb_5TAg,9016
|
|
18
18
|
if_cli/commands/config.py,sha256=1KAxjme897NIbXTGBw1rNJlXnFvhbEUf5MAli8A93ns,1724
|
|
19
19
|
if_cli/commands/login.py,sha256=vxsYcRZWsaNEzekyN1zy4ROJO-OCGUpjaVUBhtbK1LY,3255
|
|
20
|
-
if_cli/commands/logout.py,sha256=
|
|
20
|
+
if_cli/commands/logout.py,sha256=TBRMoK1EafYny9fUqx_YHj9PiPPRTKBcfQ-rOtwHPl0,692
|
|
21
21
|
if_cli/commands/mcp.py,sha256=iaSsqWkV1lnFjIw9y1pX1ORB1MdKiOVWgK23EDIsDB4,7342
|
|
22
|
-
if_cli/commands/profiles.py,sha256=
|
|
23
|
-
if_cli/commands/set_token.py,sha256=
|
|
22
|
+
if_cli/commands/profiles.py,sha256=IAqifVs0gBEijYSEY_9AgiHarQsEaNCN0q0-rjSBmrA,3948
|
|
23
|
+
if_cli/commands/set_token.py,sha256=u1nRAFRQwf3k_xYA3g3yW4Sfy5vbnKuwUBFK9nueWj8,1878
|
|
24
24
|
if_cli/commands/token.py,sha256=CyiXkyH7vxgH91-n-XJzeHnLI6mZJwO9Js5AHvfHaeM,1120
|
|
25
25
|
if_cli/router/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
26
|
if_cli/router/catalog.py,sha256=Er2ZbWTwDUVU-qBGCvyA0bIlFk3UYw_6sVUlYwBhMpI,4992
|
|
@@ -29,8 +29,8 @@ if_cli/router/log.py,sha256=wtaqsFcD3hPJRu4jCJaDMSedj-43Y07ym62YgrOm1yM,740
|
|
|
29
29
|
if_cli/router/policy.py,sha256=CclbjuOA5mwwoQiNDa17iQeN9eXRbfy11bS9JFd8gL4,2840
|
|
30
30
|
if_cli/router/server.py,sha256=yCjGAm-tnZBOTg5AfvojnLcKcZLEbQQeT9iqqX8XV5A,27397
|
|
31
31
|
if_cli/router/upstream.py,sha256=Ly4fcuQRywS6db2oDYoe8F4c9hryN75FdBqSWzA09g4,18720
|
|
32
|
-
insightfactory_cli-1.
|
|
33
|
-
insightfactory_cli-1.
|
|
34
|
-
insightfactory_cli-1.
|
|
35
|
-
insightfactory_cli-1.
|
|
36
|
-
insightfactory_cli-1.
|
|
32
|
+
insightfactory_cli-1.3.0.dev39.dist-info/METADATA,sha256=PLMGAAIwD1UpeUXG1BQH3Uy-BKiJlJOZAXtvR89yrIo,26005
|
|
33
|
+
insightfactory_cli-1.3.0.dev39.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
34
|
+
insightfactory_cli-1.3.0.dev39.dist-info/entry_points.txt,sha256=UlX854D7f-7dBj5F0wGdakVy6vopvP9hA1BN-gv739w,44
|
|
35
|
+
insightfactory_cli-1.3.0.dev39.dist-info/licenses/LICENSE,sha256=8eZ1YAABL398qESVJc_FlK1voYQ0GAh-R_rNBQK6QH8,234
|
|
36
|
+
insightfactory_cli-1.3.0.dev39.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|