insightfactory-cli 1.0.1.dev9__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.dev9 → insightfactory_cli-1.0.1.dev10}/PKG-INFO +22 -1
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/README.md +21 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/pyproject.toml +1 -1
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/api.py +32 -13
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/constants.py +1 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/http.py +54 -4
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/main.py +5 -4
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/oauth.py +9 -2
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_api.py +13 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_api_command.py +157 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_cli.py +4 -3
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/.github/workflows/ci.yml +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/.github/workflows/claude.yml +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/.github/workflows/release.yml +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/.gitignore +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/.python-version +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/AGENTS.md +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/CLAUDE.md +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/__init__.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/__main__.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/cache.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/cli.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/colour.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/__init__.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/config.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/login.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/logout.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/profiles.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/set_token.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/token.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/config.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/runtime.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/__init__.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/cache_writer.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/conftest.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/helpers.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/servers.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_cache.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_config.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_config_command.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_login.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_oauth.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_oauth_flow.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_profiles.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_programmatic_api.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_runtime.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_set_token.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/tests/test_token.py +0 -0
- {insightfactory_cli-1.0.1.dev9 → 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,6 +103,23 @@ 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
|
|
|
@@ -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,6 +87,23 @@ 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
|
|
|
@@ -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
|
|
|
@@ -109,9 +117,9 @@ def resolve_api_url(host: str, api_path: str) -> ParseResult:
|
|
|
109
117
|
return url
|
|
110
118
|
|
|
111
119
|
|
|
112
|
-
def _fetch_openapi(host: str) -> dict[str, Any]:
|
|
120
|
+
def _fetch_openapi(host: str, timeout: float) -> dict[str, Any]:
|
|
113
121
|
url = f"{host}/swagger/v1/swagger.json"
|
|
114
|
-
response = fetch_with_timeout(url)
|
|
122
|
+
response = fetch_with_timeout(url, timeout=timeout)
|
|
115
123
|
if not response.ok:
|
|
116
124
|
die(f"API discovery failed for {host} (HTTP {response.status})")
|
|
117
125
|
try:
|
|
@@ -125,16 +133,16 @@ def _fetch_openapi(host: str) -> dict[str, Any]:
|
|
|
125
133
|
return body
|
|
126
134
|
|
|
127
135
|
|
|
128
|
-
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:
|
|
129
137
|
profile = get_profile(load_config(), resolve_profile_name(profile_name))
|
|
130
138
|
filter_text = positionals[1] if len(positionals) > 1 else None
|
|
131
|
-
routes = discover_routes(_fetch_openapi(profile["host"]), filter_text)
|
|
139
|
+
routes = discover_routes(_fetch_openapi(profile["host"], timeout), filter_text)
|
|
132
140
|
if not routes:
|
|
133
141
|
die(f"no API routes matched '{filter_text or ''}'")
|
|
134
142
|
sys.stdout.write(f"{chr(10).join(routes)}\n")
|
|
135
143
|
|
|
136
144
|
|
|
137
|
-
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:
|
|
138
146
|
method = positionals[1].upper() if len(positionals) > 1 and positionals[1] else None
|
|
139
147
|
path = positionals[2] if len(positionals) > 2 else None
|
|
140
148
|
if not method or not path:
|
|
@@ -143,7 +151,7 @@ def _describe_command(positionals: list[str], profile_name: str | None) -> None:
|
|
|
143
151
|
_die_on_msys_mangled_path(path)
|
|
144
152
|
die(f"API path must begin with '/'; '{path}' is not a discovery document path")
|
|
145
153
|
profile = get_profile(load_config(), resolve_profile_name(profile_name))
|
|
146
|
-
operation = describe_route(_fetch_openapi(profile["host"]), method, path)
|
|
154
|
+
operation = describe_route(_fetch_openapi(profile["host"], timeout), method, path)
|
|
147
155
|
if not operation:
|
|
148
156
|
die(f"{method} {path} was not found in the API discovery document")
|
|
149
157
|
sys.stdout.write(f"{json.dumps({'method': method, 'path': path, **operation}, indent=2)}\n")
|
|
@@ -151,11 +159,15 @@ def _describe_command(positionals: list[str], profile_name: str | None) -> None:
|
|
|
151
159
|
|
|
152
160
|
def api_command(argv: list[str]) -> None:
|
|
153
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)
|
|
154
166
|
if positionals and positionals[0] == "routes":
|
|
155
|
-
_routes_command(positionals, values["profile"])
|
|
167
|
+
_routes_command(positionals, values["profile"], timeout)
|
|
156
168
|
return
|
|
157
169
|
if positionals and positionals[0] == "describe":
|
|
158
|
-
_describe_command(positionals, values["profile"])
|
|
170
|
+
_describe_command(positionals, values["profile"], timeout)
|
|
159
171
|
return
|
|
160
172
|
|
|
161
173
|
api_path = positionals[0] if positionals else None
|
|
@@ -178,7 +190,14 @@ def api_command(argv: list[str]) -> None:
|
|
|
178
190
|
headers = {"Authorization": f"Bearer {token}"}
|
|
179
191
|
if values["data"] is not None:
|
|
180
192
|
headers["Content-Type"] = "application/json"
|
|
181
|
-
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
|
+
)
|
|
182
201
|
sys.stderr.write(f"HTTP {response.status}\n")
|
|
183
202
|
body = response.text()
|
|
184
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()
|
|
@@ -77,6 +77,19 @@ def test_api_suggests_a_placeholder_path_when_only_a_method_was_supplied():
|
|
|
77
77
|
assert "did you mean: if-cli api -X POST /api/path ?" in result.stderr
|
|
78
78
|
|
|
79
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
|
+
|
|
80
93
|
def test_discovers_and_sorts_all_openapi_operations():
|
|
81
94
|
assert discover_routes(OPENAPI) == [
|
|
82
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.dev9 → insightfactory_cli-1.0.1.dev10}/.github/workflows/claude.yml
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev9 → 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
|
|
File without changes
|
{insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/__init__.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/config.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/login.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/logout.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/profiles.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.1.dev10}/src/if_cli/commands/set_token.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.1.dev9 → 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.dev9 → 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.dev9 → 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
|