insightfactory-cli 1.0.1.dev6__tar.gz → 1.0.1.dev10__tar.gz
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.
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/CLAUDE.md +1 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/PKG-INFO +28 -1
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/README.md +27 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/pyproject.toml +1 -1
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/api.py +54 -18
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/constants.py +1 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/http.py +54 -4
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/main.py +5 -4
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/oauth.py +9 -2
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_api.py +53 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_api_command.py +157 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_cli.py +4 -3
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/.github/workflows/ci.yml +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/.github/workflows/claude.yml +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/.github/workflows/release.yml +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/.gitignore +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/.python-version +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/AGENTS.md +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/__init__.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/__main__.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/cache.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/cli.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/colour.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/__init__.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/config.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/login.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/logout.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/profiles.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/set_token.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/token.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/config.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/runtime.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/__init__.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/cache_writer.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/conftest.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/helpers.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/servers.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_cache.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_config.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_config_command.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_login.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_oauth.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_oauth_flow.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_profiles.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_programmatic_api.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_runtime.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_set_token.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_token.py +0 -0
- {insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: insightfactory-cli
|
|
3
|
-
Version: 1.0.1.
|
|
3
|
+
Version: 1.0.1.dev10
|
|
4
4
|
Summary: Profile-based authentication CLI for the InsightFactory Interfaces API
|
|
5
5
|
Project-URL: Homepage, https://github.com/insightfactory-ai/insightfactory-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/insightfactory-ai/insightfactory-cli
|
|
@@ -78,6 +78,10 @@ if-cli token -p example-dev --env-name INSIGHTFACTORY_ACCESS_TOKEN_DEV
|
|
|
78
78
|
if-cli api -p example-dev /api/agent-projects
|
|
79
79
|
if-cli api -p example-dev -X POST -d '{"name":"example"}' /api/example
|
|
80
80
|
|
|
81
|
+
# Wait longer than the 30-second default for a long-running endpoint.
|
|
82
|
+
if-cli api -p example-dev --timeout 300 -X PUT -d '{"productionLineCodes":["PL001"]}' \
|
|
83
|
+
/api/orchestration/run
|
|
84
|
+
|
|
81
85
|
# Discover API routes from the factory's OpenAPI document.
|
|
82
86
|
if-cli api routes -p example-dev
|
|
83
87
|
if-cli api routes -p example-dev agent-projects
|
|
@@ -99,9 +103,32 @@ factory origin so a profile token cannot be forwarded to another host. Profile
|
|
|
99
103
|
hosts must be bare origins rather than URLs containing application path
|
|
100
104
|
prefixes.
|
|
101
105
|
|
|
106
|
+
Requests time out after 30 seconds by default. Raise the deadline per
|
|
107
|
+
invocation with `--timeout <seconds>`, or for a whole session with
|
|
108
|
+
`INSIGHTFACTORY_REQUEST_TIMEOUT`; the flag wins over the environment variable,
|
|
109
|
+
and both are validated before the command touches the network. There is no
|
|
110
|
+
upper bound, because the right ceiling depends on the endpoint.
|
|
111
|
+
|
|
112
|
+
Both apply to the API request itself. The OAuth discovery and token-refresh
|
|
113
|
+
round trips keep the 30-second default, so `if-cli token` and in-process callers
|
|
114
|
+
of `if_cli.oauth.get_valid_token` always have a bounded wait that no ambient
|
|
115
|
+
environment value can redefine.
|
|
116
|
+
|
|
117
|
+
A timeout is a client-side deadline, not a rejection: the factory may have
|
|
118
|
+
accepted and completed the request after `if-cli` gave up. Re-issuing a
|
|
119
|
+
non-idempotent call (`POST`, `PUT`, `PATCH`, `DELETE`) can therefore apply it
|
|
120
|
+
twice, so the timeout message for those methods says to confirm the current
|
|
121
|
+
state before retrying rather than repeating the call.
|
|
122
|
+
|
|
102
123
|
Override the config directory with `INSIGHTFACTORY_CONFIG_DIR` (useful in tests
|
|
103
124
|
and CI). The default is `~/.insightfactory`.
|
|
104
125
|
|
|
126
|
+
The request method goes in `-X`; `if-cli api` takes exactly one positional
|
|
127
|
+
argument and reads it as the path. A `curl`-shaped `if-cli api GET /api/schedules`
|
|
128
|
+
therefore parses `GET` as the path — write `if-cli api -X GET /api/schedules`
|
|
129
|
+
instead. The CLI names the misplaced verb rather than reporting a problem with
|
|
130
|
+
`/api/schedules`.
|
|
131
|
+
|
|
105
132
|
On Git Bash (MSYS2) on Windows, path conversion rewrites a leading-slash
|
|
106
133
|
argument such as `/api/agent-projects` into a Windows path. Prefix the command
|
|
107
134
|
with `MSYS_NO_PATHCONV=1`:
|
|
@@ -62,6 +62,10 @@ if-cli token -p example-dev --env-name INSIGHTFACTORY_ACCESS_TOKEN_DEV
|
|
|
62
62
|
if-cli api -p example-dev /api/agent-projects
|
|
63
63
|
if-cli api -p example-dev -X POST -d '{"name":"example"}' /api/example
|
|
64
64
|
|
|
65
|
+
# Wait longer than the 30-second default for a long-running endpoint.
|
|
66
|
+
if-cli api -p example-dev --timeout 300 -X PUT -d '{"productionLineCodes":["PL001"]}' \
|
|
67
|
+
/api/orchestration/run
|
|
68
|
+
|
|
65
69
|
# Discover API routes from the factory's OpenAPI document.
|
|
66
70
|
if-cli api routes -p example-dev
|
|
67
71
|
if-cli api routes -p example-dev agent-projects
|
|
@@ -83,9 +87,32 @@ factory origin so a profile token cannot be forwarded to another host. Profile
|
|
|
83
87
|
hosts must be bare origins rather than URLs containing application path
|
|
84
88
|
prefixes.
|
|
85
89
|
|
|
90
|
+
Requests time out after 30 seconds by default. Raise the deadline per
|
|
91
|
+
invocation with `--timeout <seconds>`, or for a whole session with
|
|
92
|
+
`INSIGHTFACTORY_REQUEST_TIMEOUT`; the flag wins over the environment variable,
|
|
93
|
+
and both are validated before the command touches the network. There is no
|
|
94
|
+
upper bound, because the right ceiling depends on the endpoint.
|
|
95
|
+
|
|
96
|
+
Both apply to the API request itself. The OAuth discovery and token-refresh
|
|
97
|
+
round trips keep the 30-second default, so `if-cli token` and in-process callers
|
|
98
|
+
of `if_cli.oauth.get_valid_token` always have a bounded wait that no ambient
|
|
99
|
+
environment value can redefine.
|
|
100
|
+
|
|
101
|
+
A timeout is a client-side deadline, not a rejection: the factory may have
|
|
102
|
+
accepted and completed the request after `if-cli` gave up. Re-issuing a
|
|
103
|
+
non-idempotent call (`POST`, `PUT`, `PATCH`, `DELETE`) can therefore apply it
|
|
104
|
+
twice, so the timeout message for those methods says to confirm the current
|
|
105
|
+
state before retrying rather than repeating the call.
|
|
106
|
+
|
|
86
107
|
Override the config directory with `INSIGHTFACTORY_CONFIG_DIR` (useful in tests
|
|
87
108
|
and CI). The default is `~/.insightfactory`.
|
|
88
109
|
|
|
110
|
+
The request method goes in `-X`; `if-cli api` takes exactly one positional
|
|
111
|
+
argument and reads it as the path. A `curl`-shaped `if-cli api GET /api/schedules`
|
|
112
|
+
therefore parses `GET` as the path — write `if-cli api -X GET /api/schedules`
|
|
113
|
+
instead. The CLI names the misplaced verb rather than reporting a problem with
|
|
114
|
+
`/api/schedules`.
|
|
115
|
+
|
|
89
116
|
On Git Bash (MSYS2) on Windows, path conversion rewrites a leading-slash
|
|
90
117
|
argument such as `/api/agent-projects` into a Windows path. Prefix the command
|
|
91
118
|
with `MSYS_NO_PATHCONV=1`:
|
|
@@ -8,7 +8,14 @@ from urllib.parse import ParseResult
|
|
|
8
8
|
|
|
9
9
|
from if_cli.cli import Options, parse_args
|
|
10
10
|
from if_cli.config import get_profile, load_config, resolve_profile_name
|
|
11
|
-
from if_cli.http import
|
|
11
|
+
from if_cli.http import (
|
|
12
|
+
fetch_with_timeout,
|
|
13
|
+
parse_timeout_seconds,
|
|
14
|
+
parse_url,
|
|
15
|
+
resolve_request_timeout,
|
|
16
|
+
url_href,
|
|
17
|
+
url_origin,
|
|
18
|
+
)
|
|
12
19
|
from if_cli.oauth import get_valid_token
|
|
13
20
|
from if_cli.runtime import die, is_record, set_exit_code
|
|
14
21
|
|
|
@@ -21,12 +28,13 @@ API_OPTIONS: Options = {
|
|
|
21
28
|
"profile": {"type": "string", "short": "p"},
|
|
22
29
|
"method": {"type": "string", "short": "X"},
|
|
23
30
|
"data": {"type": "string", "short": "d"},
|
|
31
|
+
"timeout": {"type": "string"},
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
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"
|
|
35
|
+
"usage: if-cli api [-p profile] [-X METHOD] [-d DATA] [--timeout SECONDS] /api/path\n"
|
|
36
|
+
" if-cli api routes [-p profile] [--timeout SECONDS] [filter]\n"
|
|
37
|
+
" if-cli api describe [-p profile] [--timeout SECONDS] METHOD /api/path"
|
|
30
38
|
)
|
|
31
39
|
|
|
32
40
|
|
|
@@ -61,6 +69,27 @@ def describe_route(document: dict[str, Any], method: str, path: str) -> dict[str
|
|
|
61
69
|
return operation if isinstance(operation, dict) else None
|
|
62
70
|
|
|
63
71
|
|
|
72
|
+
def _is_absolute_http_url(api_path: str) -> bool:
|
|
73
|
+
parsed = parse_url(api_path)
|
|
74
|
+
return parsed.scheme in {"http", "https"} and bool(parsed.hostname)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _die_on_misplaced_http_method(api_path: str, positionals: list[str]) -> None:
|
|
78
|
+
"""Explain a `curl`-shaped `if-cli api GET /path`, where the verb is read as the path.
|
|
79
|
+
|
|
80
|
+
Recognition spans every verb a user might reach for, not just the ones -X accepts:
|
|
81
|
+
naming the misplaced verb is more useful than a path error even for TRACE, which
|
|
82
|
+
-X then rejects on its own terms.
|
|
83
|
+
"""
|
|
84
|
+
method = api_path.lower()
|
|
85
|
+
if method not in HTTP_METHODS:
|
|
86
|
+
return
|
|
87
|
+
targets = [value for value in positionals[1:] if value.startswith("/") or _is_absolute_http_url(value)]
|
|
88
|
+
target = targets[0] if targets else "/api/path"
|
|
89
|
+
suggestion = f"if-cli api -X {method.upper()} {target}"
|
|
90
|
+
die(f"'{api_path}' looks like an HTTP method, not a path — did you mean: {suggestion} ?")
|
|
91
|
+
|
|
92
|
+
|
|
64
93
|
def _die_on_msys_mangled_path(api_path: str) -> None:
|
|
65
94
|
if not WINDOWS_DRIVE_PATH.search(api_path):
|
|
66
95
|
return
|
|
@@ -73,11 +102,6 @@ def _die_on_msys_mangled_path(api_path: str) -> None:
|
|
|
73
102
|
)
|
|
74
103
|
|
|
75
104
|
|
|
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
105
|
def resolve_api_url(host: str, api_path: str) -> ParseResult:
|
|
82
106
|
if not api_path.startswith("/") and not _is_absolute_http_url(api_path):
|
|
83
107
|
_die_on_msys_mangled_path(api_path)
|
|
@@ -93,9 +117,9 @@ def resolve_api_url(host: str, api_path: str) -> ParseResult:
|
|
|
93
117
|
return url
|
|
94
118
|
|
|
95
119
|
|
|
96
|
-
def _fetch_openapi(host: str) -> dict[str, Any]:
|
|
120
|
+
def _fetch_openapi(host: str, timeout: float) -> dict[str, Any]:
|
|
97
121
|
url = f"{host}/swagger/v1/swagger.json"
|
|
98
|
-
response = fetch_with_timeout(url)
|
|
122
|
+
response = fetch_with_timeout(url, timeout=timeout)
|
|
99
123
|
if not response.ok:
|
|
100
124
|
die(f"API discovery failed for {host} (HTTP {response.status})")
|
|
101
125
|
try:
|
|
@@ -109,16 +133,16 @@ def _fetch_openapi(host: str) -> dict[str, Any]:
|
|
|
109
133
|
return body
|
|
110
134
|
|
|
111
135
|
|
|
112
|
-
def _routes_command(positionals: list[str], profile_name: str | None) -> None:
|
|
136
|
+
def _routes_command(positionals: list[str], profile_name: str | None, timeout: float) -> None:
|
|
113
137
|
profile = get_profile(load_config(), resolve_profile_name(profile_name))
|
|
114
138
|
filter_text = positionals[1] if len(positionals) > 1 else None
|
|
115
|
-
routes = discover_routes(_fetch_openapi(profile["host"]), filter_text)
|
|
139
|
+
routes = discover_routes(_fetch_openapi(profile["host"], timeout), filter_text)
|
|
116
140
|
if not routes:
|
|
117
141
|
die(f"no API routes matched '{filter_text or ''}'")
|
|
118
142
|
sys.stdout.write(f"{chr(10).join(routes)}\n")
|
|
119
143
|
|
|
120
144
|
|
|
121
|
-
def _describe_command(positionals: list[str], profile_name: str | None) -> None:
|
|
145
|
+
def _describe_command(positionals: list[str], profile_name: str | None, timeout: float) -> None:
|
|
122
146
|
method = positionals[1].upper() if len(positionals) > 1 and positionals[1] else None
|
|
123
147
|
path = positionals[2] if len(positionals) > 2 else None
|
|
124
148
|
if not method or not path:
|
|
@@ -127,7 +151,7 @@ def _describe_command(positionals: list[str], profile_name: str | None) -> None:
|
|
|
127
151
|
_die_on_msys_mangled_path(path)
|
|
128
152
|
die(f"API path must begin with '/'; '{path}' is not a discovery document path")
|
|
129
153
|
profile = get_profile(load_config(), resolve_profile_name(profile_name))
|
|
130
|
-
operation = describe_route(_fetch_openapi(profile["host"]), method, path)
|
|
154
|
+
operation = describe_route(_fetch_openapi(profile["host"], timeout), method, path)
|
|
131
155
|
if not operation:
|
|
132
156
|
die(f"{method} {path} was not found in the API discovery document")
|
|
133
157
|
sys.stdout.write(f"{json.dumps({'method': method, 'path': path, **operation}, indent=2)}\n")
|
|
@@ -135,16 +159,21 @@ def _describe_command(positionals: list[str], profile_name: str | None) -> None:
|
|
|
135
159
|
|
|
136
160
|
def api_command(argv: list[str]) -> None:
|
|
137
161
|
values, positionals = parse_args(argv, options=API_OPTIONS, allow_positionals=True)
|
|
162
|
+
# Resolved up front so an unusable --timeout or environment value fails before
|
|
163
|
+
# the command reaches for a profile, a token, or the network.
|
|
164
|
+
flag = parse_timeout_seconds(values["timeout"], "--timeout") if values["timeout"] is not None else None
|
|
165
|
+
timeout = resolve_request_timeout(flag)
|
|
138
166
|
if positionals and positionals[0] == "routes":
|
|
139
|
-
_routes_command(positionals, values["profile"])
|
|
167
|
+
_routes_command(positionals, values["profile"], timeout)
|
|
140
168
|
return
|
|
141
169
|
if positionals and positionals[0] == "describe":
|
|
142
|
-
_describe_command(positionals, values["profile"])
|
|
170
|
+
_describe_command(positionals, values["profile"], timeout)
|
|
143
171
|
return
|
|
144
172
|
|
|
145
173
|
api_path = positionals[0] if positionals else None
|
|
146
174
|
if not api_path:
|
|
147
175
|
die(API_USAGE)
|
|
176
|
+
_die_on_misplaced_http_method(api_path, positionals)
|
|
148
177
|
|
|
149
178
|
if values["method"] is not None:
|
|
150
179
|
method = values["method"].upper()
|
|
@@ -161,7 +190,14 @@ def api_command(argv: list[str]) -> None:
|
|
|
161
190
|
headers = {"Authorization": f"Bearer {token}"}
|
|
162
191
|
if values["data"] is not None:
|
|
163
192
|
headers["Content-Type"] = "application/json"
|
|
164
|
-
response = fetch_with_timeout(
|
|
193
|
+
response = fetch_with_timeout(
|
|
194
|
+
url,
|
|
195
|
+
method=method,
|
|
196
|
+
headers=headers,
|
|
197
|
+
body=values["data"],
|
|
198
|
+
timeout=timeout,
|
|
199
|
+
warn_unsafe_retry=True,
|
|
200
|
+
)
|
|
165
201
|
sys.stderr.write(f"HTTP {response.status}\n")
|
|
166
202
|
body = response.text()
|
|
167
203
|
if body:
|
|
@@ -1,15 +1,43 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
4
6
|
import socket
|
|
5
7
|
import urllib.error
|
|
6
8
|
import urllib.request
|
|
7
9
|
from email.message import Message
|
|
10
|
+
from typing import NoReturn
|
|
8
11
|
from urllib.parse import ParseResult, urljoin, urlparse
|
|
9
12
|
|
|
13
|
+
from if_cli.constants import ENV_REQUEST_TIMEOUT
|
|
10
14
|
from if_cli.runtime import die
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
DEFAULT_REQUEST_TIMEOUT_S = 30.0
|
|
17
|
+
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_timeout_seconds(value: str, label: str) -> float:
|
|
21
|
+
"""Parse a request timeout in seconds. There is no upper bound: the right
|
|
22
|
+
ceiling depends on the endpoint, and a long factory call must be waitable."""
|
|
23
|
+
try:
|
|
24
|
+
timeout = float(value)
|
|
25
|
+
except ValueError:
|
|
26
|
+
die(f"{label} must be a positive number of seconds (received '{value}')")
|
|
27
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
28
|
+
die(f"{label} must be a positive number of seconds (received '{value}')")
|
|
29
|
+
return timeout
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_request_timeout(explicit: float | None = None) -> float:
|
|
33
|
+
"""Per-invocation timeout, in seconds: explicit argument, then the
|
|
34
|
+
environment variable, then the default."""
|
|
35
|
+
if explicit is not None:
|
|
36
|
+
return explicit
|
|
37
|
+
from_environment = os.environ.get(ENV_REQUEST_TIMEOUT)
|
|
38
|
+
if from_environment is not None and from_environment.strip():
|
|
39
|
+
return parse_timeout_seconds(from_environment.strip(), ENV_REQUEST_TIMEOUT)
|
|
40
|
+
return DEFAULT_REQUEST_TIMEOUT_S
|
|
13
41
|
|
|
14
42
|
|
|
15
43
|
def _with_backslashes_as_slashes(value: str) -> str:
|
|
@@ -122,13 +150,34 @@ class HttpResponse:
|
|
|
122
150
|
return str(value)
|
|
123
151
|
|
|
124
152
|
|
|
153
|
+
def _die_on_timeout(url: str, method: str, timeout: float, *, warn_unsafe_retry: bool) -> NoReturn:
|
|
154
|
+
if not warn_unsafe_retry or method.upper() in SAFE_METHODS:
|
|
155
|
+
die(f"request timed out after {timeout}s: {url}")
|
|
156
|
+
die(
|
|
157
|
+
f"request timed out after {timeout}s: {url}\n"
|
|
158
|
+
f" The timeout is a client-side deadline, not a rejection — the factory may still have\n"
|
|
159
|
+
f" accepted and completed this {method.upper()}. Do not re-issue it: confirm the\n"
|
|
160
|
+
" current state first, and retry only if the work did not happen. Raise the\n"
|
|
161
|
+
" deadline with --timeout <seconds>."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
125
165
|
def fetch_with_timeout(
|
|
126
166
|
url: str,
|
|
127
167
|
*,
|
|
128
168
|
method: str = "GET",
|
|
129
169
|
headers: dict[str, str] | None = None,
|
|
130
170
|
body: str | bytes | None = None,
|
|
171
|
+
timeout: float | None = None,
|
|
172
|
+
warn_unsafe_retry: bool = False,
|
|
131
173
|
) -> HttpResponse:
|
|
174
|
+
"""Issue one request, dying on timeout.
|
|
175
|
+
|
|
176
|
+
`warn_unsafe_retry` opts into the confirm-don't-retry guidance, which only makes
|
|
177
|
+
sense for a request the caller issued on the user's behalf against the factory.
|
|
178
|
+
It is wrong advice for an OAuth round trip, where retrying is the correct response
|
|
179
|
+
and there is no factory-side state to confirm.
|
|
180
|
+
"""
|
|
132
181
|
data: bytes | None
|
|
133
182
|
if body is None:
|
|
134
183
|
data = None
|
|
@@ -137,8 +186,9 @@ def fetch_with_timeout(
|
|
|
137
186
|
else:
|
|
138
187
|
data = body.encode("utf-8")
|
|
139
188
|
request = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
|
|
189
|
+
effective_timeout = resolve_request_timeout(timeout)
|
|
140
190
|
try:
|
|
141
|
-
with urllib.request.urlopen(request, timeout=
|
|
191
|
+
with urllib.request.urlopen(request, timeout=effective_timeout) as response:
|
|
142
192
|
return HttpResponse(response.status, response.headers, response.read())
|
|
143
193
|
except urllib.error.HTTPError as error:
|
|
144
194
|
return HttpResponse(error.code, error.headers, error.read())
|
|
@@ -147,7 +197,7 @@ def fetch_with_timeout(
|
|
|
147
197
|
if isinstance(reason, (TimeoutError, socket.timeout)) or getattr(reason, "errno", None) == getattr(
|
|
148
198
|
socket, "ETIMEDOUT", None
|
|
149
199
|
):
|
|
150
|
-
|
|
200
|
+
_die_on_timeout(url, method, effective_timeout, warn_unsafe_retry=warn_unsafe_retry)
|
|
151
201
|
raise
|
|
152
202
|
except TimeoutError:
|
|
153
|
-
|
|
203
|
+
_die_on_timeout(url, method, effective_timeout, warn_unsafe_retry=warn_unsafe_retry)
|
|
@@ -10,7 +10,7 @@ from if_cli.commands.logout import logout_command
|
|
|
10
10
|
from if_cli.commands.profiles import profiles_command
|
|
11
11
|
from if_cli.commands.set_token import set_token_command
|
|
12
12
|
from if_cli.commands.token import token_command
|
|
13
|
-
from if_cli.constants import ENV_PROFILE
|
|
13
|
+
from if_cli.constants import ENV_PROFILE, ENV_REQUEST_TIMEOUT
|
|
14
14
|
from if_cli.runtime import die
|
|
15
15
|
|
|
16
16
|
USAGE = f"""usage: if-cli <command> [options]
|
|
@@ -27,11 +27,12 @@ commands:
|
|
|
27
27
|
config print a non-secret profile value
|
|
28
28
|
get <host|audience|callback_port|client_id|organization> [-p profile]
|
|
29
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>
|
|
30
|
+
[-p profile] [-X METHOD] [-d DATA] [--timeout SECONDS] <path>
|
|
31
|
+
routes [-p profile] [--timeout SECONDS] [filter]
|
|
32
|
+
describe [-p profile] [--timeout SECONDS] METHOD <path>
|
|
33
33
|
|
|
34
34
|
profile selection: -p flag, then ${ENV_PROFILE}, then [DEFAULT].
|
|
35
|
+
request timeout: --timeout flag, then ${ENV_REQUEST_TIMEOUT}, then 30 seconds.
|
|
35
36
|
"""
|
|
36
37
|
|
|
37
38
|
|
|
@@ -20,7 +20,7 @@ from if_cli.cache import (
|
|
|
20
20
|
)
|
|
21
21
|
from if_cli.config import Profile, meaningful
|
|
22
22
|
from if_cli.constants import DEFAULT_SCOPE, LOOPBACK_HOST
|
|
23
|
-
from if_cli.http import fetch_with_timeout, validate_http_url
|
|
23
|
+
from if_cli.http import DEFAULT_REQUEST_TIMEOUT_S, fetch_with_timeout, validate_http_url
|
|
24
24
|
from if_cli.runtime import CliError, die, escape_html, is_record
|
|
25
25
|
|
|
26
26
|
CALLBACK_TIMEOUT_SECONDS = 300
|
|
@@ -66,7 +66,10 @@ def build_callback_url(port: int) -> str:
|
|
|
66
66
|
|
|
67
67
|
|
|
68
68
|
def _discover(host: str) -> dict[str, Any]:
|
|
69
|
-
response = fetch_with_timeout(
|
|
69
|
+
response = fetch_with_timeout(
|
|
70
|
+
f"{host}/.well-known/oauth-authorization-server",
|
|
71
|
+
timeout=DEFAULT_REQUEST_TIMEOUT_S,
|
|
72
|
+
)
|
|
70
73
|
if not response.ok:
|
|
71
74
|
die(f"OAuth discovery failed for {host} (HTTP {response.status})")
|
|
72
75
|
try:
|
|
@@ -114,11 +117,15 @@ def _parse_expires_in(value: object) -> float | None:
|
|
|
114
117
|
|
|
115
118
|
|
|
116
119
|
def _token_request(token_endpoint: str, form: dict[str, str]) -> dict[str, object]:
|
|
120
|
+
# Pinned to the default rather than the caller's timeout: get_valid_token is a
|
|
121
|
+
# documented in-process surface that promises a bounded wait, and an OAuth round
|
|
122
|
+
# trip has no reason to inherit a ceiling chosen for a slow factory endpoint.
|
|
117
123
|
response = fetch_with_timeout(
|
|
118
124
|
token_endpoint,
|
|
119
125
|
method="POST",
|
|
120
126
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
121
127
|
body=urlencode(form),
|
|
128
|
+
timeout=DEFAULT_REQUEST_TIMEOUT_S,
|
|
122
129
|
)
|
|
123
130
|
if not response.ok:
|
|
124
131
|
body = response.text()
|
|
@@ -37,6 +37,59 @@ def test_api_rejects_unsupported_request_methods_with_a_cli_error():
|
|
|
37
37
|
assert "unsupported request method 'TRACE'" in result.stderr
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def test_api_names_a_misplaced_http_method_instead_of_blaming_the_path():
|
|
41
|
+
result = run_cli(["api", "GET", "/api/schedules"])
|
|
42
|
+
assert result.returncode == 1
|
|
43
|
+
assert "'GET' looks like an HTTP method, not a path" in result.stderr
|
|
44
|
+
assert "did you mean: if-cli api -X GET /api/schedules ?" in result.stderr
|
|
45
|
+
assert "API path must begin with" not in result.stderr
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_api_detects_a_misplaced_http_method_in_any_case_and_position():
|
|
49
|
+
lowercase = run_cli(["api", "put", "/api/orchestration/run"])
|
|
50
|
+
assert lowercase.returncode == 1
|
|
51
|
+
assert "'put' looks like an HTTP method, not a path" in lowercase.stderr
|
|
52
|
+
assert "did you mean: if-cli api -X PUT /api/orchestration/run ?" in lowercase.stderr
|
|
53
|
+
|
|
54
|
+
before_profile = run_cli(["api", "DELETE", "/api/schedules/1", "-p", "example-dev"])
|
|
55
|
+
assert before_profile.returncode == 1
|
|
56
|
+
assert "did you mean: if-cli api -X DELETE /api/schedules/1 ?" in before_profile.stderr
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_api_keeps_a_same_origin_absolute_url_in_the_correction():
|
|
60
|
+
result = run_cli(["api", "GET", "https://factory.example/api/schedules"])
|
|
61
|
+
assert result.returncode == 1
|
|
62
|
+
assert "did you mean: if-cli api -X GET https://factory.example/api/schedules ?" in result.stderr
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_api_names_a_misplaced_verb_that_x_would_not_accept():
|
|
66
|
+
result = run_cli(["api", "TRACE", "/api/schedules"])
|
|
67
|
+
assert result.returncode == 1
|
|
68
|
+
assert "'TRACE' looks like an HTTP method, not a path" in result.stderr
|
|
69
|
+
assert "did you mean: if-cli api -X TRACE /api/schedules ?" in result.stderr
|
|
70
|
+
assert "API path must begin with" not in result.stderr
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_api_suggests_a_placeholder_path_when_only_a_method_was_supplied():
|
|
74
|
+
result = run_cli(["api", "POST"])
|
|
75
|
+
assert result.returncode == 1
|
|
76
|
+
assert "'POST' looks like an HTTP method, not a path" in result.stderr
|
|
77
|
+
assert "did you mean: if-cli api -X POST /api/path ?" in result.stderr
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_api_rejects_a_timeout_that_is_not_a_positive_number_of_seconds():
|
|
81
|
+
for value in ["0", "-1", "abc", "nan", "inf", ""]:
|
|
82
|
+
result = run_cli(["api", f"--timeout={value}", "/api/health"])
|
|
83
|
+
assert result.returncode == 1, value
|
|
84
|
+
assert "--timeout must be a positive number of seconds" in result.stderr, value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_api_rejects_an_invalid_request_timeout_from_the_environment():
|
|
88
|
+
result = run_cli(["api", "/api/health"], env={"INSIGHTFACTORY_REQUEST_TIMEOUT": "soon"})
|
|
89
|
+
assert result.returncode == 1
|
|
90
|
+
assert "INSIGHTFACTORY_REQUEST_TIMEOUT must be a positive number of seconds" in result.stderr
|
|
91
|
+
|
|
92
|
+
|
|
40
93
|
def test_discovers_and_sorts_all_openapi_operations():
|
|
41
94
|
assert discover_routes(OPENAPI) == [
|
|
42
95
|
"DELETE /api/agent-projects/{id} Delete an agent project",
|
|
@@ -6,9 +6,16 @@ import time
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from if_cli.http import fetch_with_timeout
|
|
12
|
+
from if_cli.runtime import CliError
|
|
9
13
|
from tests.helpers import create_config, run_cli
|
|
10
14
|
from tests.servers import Request, Response, ThreadedServer
|
|
11
15
|
|
|
16
|
+
SLOW_RESPONSE_S = 0.6
|
|
17
|
+
SHORT_TIMEOUT_S = "0.1"
|
|
18
|
+
|
|
12
19
|
|
|
13
20
|
def _api_server(mode: str = "valid") -> tuple[ThreadedServer, dict[str, Any]]:
|
|
14
21
|
state: dict[str, Any] = {"return_null_swagger": False}
|
|
@@ -35,6 +42,9 @@ def _api_server(mode: str = "valid") -> tuple[ThreadedServer, dict[str, Any]]:
|
|
|
35
42
|
"contentType": request.headers.get("Content-Type") or request.headers.get("content-type"),
|
|
36
43
|
}
|
|
37
44
|
)
|
|
45
|
+
if request.path == "/slow":
|
|
46
|
+
time.sleep(SLOW_RESPONSE_S)
|
|
47
|
+
return Response(body={"accepted": True})
|
|
38
48
|
if request.path == "/invalid-json":
|
|
39
49
|
return Response(body="not-json", headers={"Content-Type": "application/json"})
|
|
40
50
|
if request.path == "/error":
|
|
@@ -322,3 +332,150 @@ def test_api_flushes_a_complete_error_body_before_exiting_unsuccessfully():
|
|
|
322
332
|
finally:
|
|
323
333
|
server.close()
|
|
324
334
|
shutil.rmtree(config_dir, ignore_errors=True)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_api_timeout_flag_raises_the_deadline_over_the_environment_default():
|
|
338
|
+
server, _state = _api_server("valid")
|
|
339
|
+
config_dir = create_config(f"[DEFAULT]\nhost = {server.origin}\n")
|
|
340
|
+
_write_token(config_dir, server.origin)
|
|
341
|
+
environment = {
|
|
342
|
+
"INSIGHTFACTORY_CONFIG_DIR": config_dir,
|
|
343
|
+
"INSIGHTFACTORY_REQUEST_TIMEOUT": SHORT_TIMEOUT_S,
|
|
344
|
+
}
|
|
345
|
+
try:
|
|
346
|
+
without_flag = run_cli(["api", "/slow"], env=environment)
|
|
347
|
+
assert without_flag.returncode == 1
|
|
348
|
+
assert f"request timed out after {float(SHORT_TIMEOUT_S)}s" in without_flag.stderr
|
|
349
|
+
|
|
350
|
+
with_flag = run_cli(["api", "--timeout", "30", "/slow"], env=environment)
|
|
351
|
+
assert with_flag.returncode == 0, with_flag.stderr
|
|
352
|
+
assert json.loads(with_flag.stdout) == {"accepted": True}
|
|
353
|
+
finally:
|
|
354
|
+
server.close()
|
|
355
|
+
shutil.rmtree(config_dir, ignore_errors=True)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_api_timeout_on_a_non_idempotent_request_says_it_may_still_have_landed():
|
|
359
|
+
server, _state = _api_server("valid")
|
|
360
|
+
config_dir = create_config(f"[DEFAULT]\nhost = {server.origin}\n")
|
|
361
|
+
_write_token(config_dir, server.origin)
|
|
362
|
+
try:
|
|
363
|
+
result = run_cli(
|
|
364
|
+
["api", "--timeout", SHORT_TIMEOUT_S, "-X", "PUT", "-d", '{"productionLineCodes":["PL001"]}', "/slow"],
|
|
365
|
+
env={"INSIGHTFACTORY_CONFIG_DIR": config_dir},
|
|
366
|
+
)
|
|
367
|
+
assert result.returncode == 1
|
|
368
|
+
assert f"request timed out after {float(SHORT_TIMEOUT_S)}s" in result.stderr
|
|
369
|
+
assert "the factory may still have" in result.stderr
|
|
370
|
+
assert "accepted and completed this PUT" in result.stderr
|
|
371
|
+
assert "Do not re-issue it" in result.stderr
|
|
372
|
+
finally:
|
|
373
|
+
server.close()
|
|
374
|
+
shutil.rmtree(config_dir, ignore_errors=True)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def test_api_timeout_on_a_read_stays_a_plain_timeout():
|
|
378
|
+
server, _state = _api_server("valid")
|
|
379
|
+
config_dir = create_config(f"[DEFAULT]\nhost = {server.origin}\n")
|
|
380
|
+
_write_token(config_dir, server.origin)
|
|
381
|
+
try:
|
|
382
|
+
result = run_cli(
|
|
383
|
+
["api", "--timeout", SHORT_TIMEOUT_S, "/slow"],
|
|
384
|
+
env={"INSIGHTFACTORY_CONFIG_DIR": config_dir},
|
|
385
|
+
)
|
|
386
|
+
assert result.returncode == 1
|
|
387
|
+
assert f"request timed out after {float(SHORT_TIMEOUT_S)}s" in result.stderr
|
|
388
|
+
assert "Do not re-issue it" not in result.stderr
|
|
389
|
+
finally:
|
|
390
|
+
server.close()
|
|
391
|
+
shutil.rmtree(config_dir, ignore_errors=True)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def test_api_routes_honours_the_timeout_flag_during_discovery():
|
|
395
|
+
server, _state = _api_server("valid")
|
|
396
|
+
config_dir = create_config(f"[DEFAULT]\nhost = {server.origin}\n")
|
|
397
|
+
try:
|
|
398
|
+
result = run_cli(
|
|
399
|
+
["api", "routes", "--timeout", "60"],
|
|
400
|
+
env={"INSIGHTFACTORY_CONFIG_DIR": config_dir},
|
|
401
|
+
)
|
|
402
|
+
assert result.returncode == 0, result.stderr
|
|
403
|
+
assert "GET /api/health" in result.stdout
|
|
404
|
+
finally:
|
|
405
|
+
server.close()
|
|
406
|
+
shutil.rmtree(config_dir, ignore_errors=True)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_token_refresh_ignores_a_short_ambient_request_timeout():
|
|
410
|
+
"""`get_valid_token` promises a bounded wait, so the auth leg keeps the default.
|
|
411
|
+
|
|
412
|
+
Before the timeout override existed there was nothing to inherit; now an ambient
|
|
413
|
+
INSIGHTFACTORY_REQUEST_TIMEOUT must not shorten a token refresh out from under an
|
|
414
|
+
in-process caller.
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
def handler(request: Request) -> Response:
|
|
418
|
+
if request.path == "/token":
|
|
419
|
+
time.sleep(SLOW_RESPONSE_S)
|
|
420
|
+
return Response(body={"access_token": "refreshed", "expires_in": 3600})
|
|
421
|
+
return Response(body={"ok": True})
|
|
422
|
+
|
|
423
|
+
server = ThreadedServer(handler).start()
|
|
424
|
+
config_dir = create_config(f"[DEFAULT]\nhost = {server.origin}\nclient_id = test-client\n")
|
|
425
|
+
Path(config_dir, "token-cache.json").write_text(
|
|
426
|
+
json.dumps(
|
|
427
|
+
{
|
|
428
|
+
"version": 1,
|
|
429
|
+
"tokens": {
|
|
430
|
+
server.origin: {
|
|
431
|
+
"access_token": "stale",
|
|
432
|
+
"refresh_token": "test-refresh",
|
|
433
|
+
"expires_at": int(time.time()) - 10,
|
|
434
|
+
"token_endpoint": f"{server.origin}/token",
|
|
435
|
+
"client_id": "test-client",
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
),
|
|
440
|
+
encoding="utf-8",
|
|
441
|
+
)
|
|
442
|
+
try:
|
|
443
|
+
result = run_cli(
|
|
444
|
+
["api", "--timeout", "30", "/api/health"],
|
|
445
|
+
env={
|
|
446
|
+
"INSIGHTFACTORY_CONFIG_DIR": config_dir,
|
|
447
|
+
"INSIGHTFACTORY_REQUEST_TIMEOUT": SHORT_TIMEOUT_S,
|
|
448
|
+
},
|
|
449
|
+
)
|
|
450
|
+
assert result.returncode == 0, result.stderr
|
|
451
|
+
assert "could not refresh token" not in result.stderr
|
|
452
|
+
assert json.loads(result.stdout) == {"ok": True}
|
|
453
|
+
finally:
|
|
454
|
+
server.close()
|
|
455
|
+
shutil.rmtree(config_dir, ignore_errors=True)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def test_only_the_opted_in_caller_gets_the_confirm_dont_retry_guidance():
|
|
459
|
+
"""A timed-out OAuth POST must not be told to confirm factory state first."""
|
|
460
|
+
|
|
461
|
+
def handler(request: Request) -> Response:
|
|
462
|
+
time.sleep(SLOW_RESPONSE_S)
|
|
463
|
+
return Response(body={"ok": True})
|
|
464
|
+
|
|
465
|
+
server = ThreadedServer(handler).start()
|
|
466
|
+
try:
|
|
467
|
+
with pytest.raises(CliError) as unopted:
|
|
468
|
+
fetch_with_timeout(f"{server.origin}/token", method="POST", timeout=float(SHORT_TIMEOUT_S))
|
|
469
|
+
assert "request timed out after" in str(unopted.value)
|
|
470
|
+
assert "Do not re-issue it" not in str(unopted.value)
|
|
471
|
+
|
|
472
|
+
with pytest.raises(CliError) as opted:
|
|
473
|
+
fetch_with_timeout(
|
|
474
|
+
f"{server.origin}/api/orchestration/run",
|
|
475
|
+
method="PUT",
|
|
476
|
+
timeout=float(SHORT_TIMEOUT_S),
|
|
477
|
+
warn_unsafe_retry=True,
|
|
478
|
+
)
|
|
479
|
+
assert "Do not re-issue it" in str(opted.value)
|
|
480
|
+
finally:
|
|
481
|
+
server.close()
|
|
@@ -212,7 +212,7 @@ def test_parse_args_accepts_positionals_after_options():
|
|
|
212
212
|
options=API_OPTIONS,
|
|
213
213
|
allow_positionals=True,
|
|
214
214
|
)
|
|
215
|
-
assert values_after == values_before == {"profile": "foundry-dev", "method": None, "data": None}
|
|
215
|
+
assert values_after == values_before == {"profile": "foundry-dev", "method": None, "data": None, "timeout": None}
|
|
216
216
|
assert positionals_after == positionals_before == ["routes", "task"]
|
|
217
217
|
|
|
218
218
|
describe_after, pos_after = parse_args(
|
|
@@ -225,7 +225,8 @@ def test_parse_args_accepts_positionals_after_options():
|
|
|
225
225
|
options=API_OPTIONS,
|
|
226
226
|
allow_positionals=True,
|
|
227
227
|
)
|
|
228
|
-
|
|
228
|
+
expected = {"profile": "foundry-dev", "method": None, "data": None, "timeout": None}
|
|
229
|
+
assert describe_after == describe_before == expected
|
|
229
230
|
assert pos_after == pos_before == ["describe", "GET", "/api/tasks"]
|
|
230
231
|
|
|
231
232
|
config_after, config_pos_after = parse_args(
|
|
@@ -248,7 +249,7 @@ def test_parse_args_treats_tokens_after_double_dash_as_positionals():
|
|
|
248
249
|
options=API_OPTIONS,
|
|
249
250
|
allow_positionals=True,
|
|
250
251
|
)
|
|
251
|
-
assert values == {"profile": "foundry-dev", "method": None, "data": None}
|
|
252
|
+
assert values == {"profile": "foundry-dev", "method": None, "data": None, "timeout": None}
|
|
252
253
|
assert positionals == ["routes", "-X", "GET"]
|
|
253
254
|
|
|
254
255
|
|
|
File without changes
|
{insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/.github/workflows/claude.yml
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/.github/workflows/release.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/__init__.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/config.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/login.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/logout.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/profiles.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/set_token.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/token.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_config_command.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{insightfactory_cli-1.0.1.dev6 → insightfactory_cli-1.0.1.dev10}/tests/test_programmatic_api.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|