insightfactory-cli 1.0.1.dev9__tar.gz → 1.0.2__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.
Files changed (50) hide show
  1. insightfactory_cli-1.0.2/LICENSE +5 -0
  2. insightfactory_cli-1.0.1.dev9/README.md → insightfactory_cli-1.0.2/PKG-INFO +44 -0
  3. insightfactory_cli-1.0.1.dev9/PKG-INFO → insightfactory_cli-1.0.2/README.md +26 -16
  4. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/pyproject.toml +5 -3
  5. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/commands/api.py +32 -13
  6. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/constants.py +1 -0
  7. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/http.py +58 -4
  8. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/main.py +5 -4
  9. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/oauth.py +12 -4
  10. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_api.py +13 -0
  11. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_api_command.py +157 -0
  12. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_cli.py +4 -3
  13. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/uv.lock +1 -1
  14. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/.github/workflows/ci.yml +0 -0
  15. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/.github/workflows/claude.yml +0 -0
  16. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/.github/workflows/release.yml +0 -0
  17. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/.gitignore +0 -0
  18. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/.python-version +0 -0
  19. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/AGENTS.md +0 -0
  20. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/CLAUDE.md +0 -0
  21. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/__init__.py +0 -0
  22. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/__main__.py +0 -0
  23. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/cache.py +0 -0
  24. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/cli.py +0 -0
  25. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/colour.py +0 -0
  26. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/commands/__init__.py +0 -0
  27. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/commands/config.py +0 -0
  28. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/commands/login.py +0 -0
  29. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/commands/logout.py +0 -0
  30. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/commands/profiles.py +0 -0
  31. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/commands/set_token.py +0 -0
  32. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/commands/token.py +0 -0
  33. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/config.py +0 -0
  34. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/src/if_cli/runtime.py +0 -0
  35. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/__init__.py +0 -0
  36. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/cache_writer.py +0 -0
  37. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/conftest.py +0 -0
  38. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/helpers.py +0 -0
  39. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/servers.py +0 -0
  40. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_cache.py +0 -0
  41. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_config.py +0 -0
  42. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_config_command.py +0 -0
  43. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_login.py +0 -0
  44. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_oauth.py +0 -0
  45. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_oauth_flow.py +0 -0
  46. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_profiles.py +0 -0
  47. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_programmatic_api.py +0 -0
  48. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_runtime.py +0 -0
  49. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_set_token.py +0 -0
  50. {insightfactory_cli-1.0.1.dev9 → insightfactory_cli-1.0.2}/tests/test_token.py +0 -0
@@ -0,0 +1,5 @@
1
+ Copyright 2026 insightfactory.ai. All rights reserved.
2
+
3
+ This software is proprietary. No permission is granted to use, copy, modify,
4
+ distribute, sublicense, or sell it except under a separate written agreement
5
+ with insightfactory.ai.
@@ -1,3 +1,21 @@
1
+ Metadata-Version: 2.5
2
+ Name: insightfactory-cli
3
+ Version: 1.0.2
4
+ Summary: Profile-based authentication CLI for the InsightFactory Interfaces API
5
+ Project-URL: Homepage, https://insightfactory.ai
6
+ Author-email: "insightfactory.ai Support" <support@insightfactory.ai>
7
+ License-Expression: LicenseRef-Proprietary
8
+ License-File: LICENSE
9
+ Keywords: cli,insightfactory,oauth,pkce
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: Other/Proprietary License
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+
1
19
  # insightfactory-cli
2
20
 
3
21
  Profile-based authentication CLI for the InsightFactory Interfaces API. It
@@ -62,6 +80,10 @@ if-cli token -p example-dev --env-name INSIGHTFACTORY_ACCESS_TOKEN_DEV
62
80
  if-cli api -p example-dev /api/agent-projects
63
81
  if-cli api -p example-dev -X POST -d '{"name":"example"}' /api/example
64
82
 
83
+ # Wait longer than the 30-second default for a long-running endpoint.
84
+ if-cli api -p example-dev --timeout 300 -X PUT -d '{"productionLineCodes":["PL001"]}' \
85
+ /api/orchestration/run
86
+
65
87
  # Discover API routes from the factory's OpenAPI document.
66
88
  if-cli api routes -p example-dev
67
89
  if-cli api routes -p example-dev agent-projects
@@ -83,6 +105,23 @@ factory origin so a profile token cannot be forwarded to another host. Profile
83
105
  hosts must be bare origins rather than URLs containing application path
84
106
  prefixes.
85
107
 
108
+ Requests time out after 30 seconds by default. Raise the deadline per
109
+ invocation with `--timeout <seconds>`, or for a whole session with
110
+ `INSIGHTFACTORY_REQUEST_TIMEOUT`; the flag wins over the environment variable,
111
+ and both are validated before the command touches the network. There is no
112
+ upper bound, because the right ceiling depends on the endpoint.
113
+
114
+ Both apply to the API request itself. The OAuth discovery and token-refresh
115
+ round trips keep the 30-second default, so `if-cli token` and in-process callers
116
+ of `if_cli.oauth.get_valid_token` always have a bounded wait that no ambient
117
+ environment value can redefine.
118
+
119
+ A timeout is a client-side deadline, not a rejection: the factory may have
120
+ accepted and completed the request after `if-cli` gave up. Re-issuing a
121
+ non-idempotent call (`POST`, `PUT`, `PATCH`, `DELETE`) can therefore apply it
122
+ twice, so the timeout message for those methods says to confirm the current
123
+ state before retrying rather than repeating the call.
124
+
86
125
  Override the config directory with `INSIGHTFACTORY_CONFIG_DIR` (useful in tests
87
126
  and CI). The default is `~/.insightfactory`.
88
127
 
@@ -271,3 +310,8 @@ genuinely diverge:
271
310
  On-disk profile and token-cache paths, JSON shapes, and file permissions stay
272
311
  compatible with the Node CLI, so you can switch implementations without
273
312
  re-authenticating.
313
+
314
+ ## Licence
315
+
316
+ Copyright 2026 insightfactory.ai. All rights reserved. This package is proprietary
317
+ and may be used only under a separate written agreement with insightfactory.ai.
@@ -1,19 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: insightfactory-cli
3
- Version: 1.0.1.dev9
4
- Summary: Profile-based authentication CLI for the InsightFactory Interfaces API
5
- Project-URL: Homepage, https://github.com/insightfactory-ai/insightfactory-cli
6
- Project-URL: Repository, https://github.com/insightfactory-ai/insightfactory-cli
7
- Author-email: "insightfactory.ai Support" <support@insightfactory.ai>
8
- Keywords: cli,insightfactory,oauth,pkce
9
- Classifier: Development Status :: 5 - Production/Stable
10
- Classifier: Environment :: Console
11
- Classifier: Intended Audience :: Developers
12
- Classifier: Programming Language :: Python :: 3 :: Only
13
- Classifier: Typing :: Typed
14
- Requires-Python: >=3.10
15
- Description-Content-Type: text/markdown
16
-
17
1
  # insightfactory-cli
18
2
 
19
3
  Profile-based authentication CLI for the InsightFactory Interfaces API. It
@@ -78,6 +62,10 @@ if-cli token -p example-dev --env-name INSIGHTFACTORY_ACCESS_TOKEN_DEV
78
62
  if-cli api -p example-dev /api/agent-projects
79
63
  if-cli api -p example-dev -X POST -d '{"name":"example"}' /api/example
80
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
+
81
69
  # Discover API routes from the factory's OpenAPI document.
82
70
  if-cli api routes -p example-dev
83
71
  if-cli api routes -p example-dev agent-projects
@@ -99,6 +87,23 @@ factory origin so a profile token cannot be forwarded to another host. Profile
99
87
  hosts must be bare origins rather than URLs containing application path
100
88
  prefixes.
101
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
+
102
107
  Override the config directory with `INSIGHTFACTORY_CONFIG_DIR` (useful in tests
103
108
  and CI). The default is `~/.insightfactory`.
104
109
 
@@ -287,3 +292,8 @@ genuinely diverge:
287
292
  On-disk profile and token-cache paths, JSON shapes, and file permissions stay
288
293
  compatible with the Node CLI, so you can switch implementations without
289
294
  re-authenticating.
295
+
296
+ ## Licence
297
+
298
+ Copyright 2026 insightfactory.ai. All rights reserved. This package is proprietary
299
+ and may be used only under a separate written agreement with insightfactory.ai.
@@ -1,15 +1,18 @@
1
1
  [project]
2
2
  name = "insightfactory-cli"
3
- version = "1.0.1.dev9"
3
+ version = "1.0.2"
4
4
  description = "Profile-based authentication CLI for the InsightFactory Interfaces API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  authors = [{ name = "insightfactory.ai Support", email = "support@insightfactory.ai" }]
8
+ license = "LicenseRef-Proprietary"
9
+ license-files = ["LICENSE"]
8
10
  keywords = ["insightfactory", "cli", "oauth", "pkce"]
9
11
  classifiers = [
10
12
  "Development Status :: 5 - Production/Stable",
11
13
  "Environment :: Console",
12
14
  "Intended Audience :: Developers",
15
+ "License :: Other/Proprietary License",
13
16
  "Programming Language :: Python :: 3 :: Only",
14
17
  "Typing :: Typed",
15
18
  ]
@@ -18,8 +21,7 @@ classifiers = [
18
21
  if-cli = "if_cli.main:main"
19
22
 
20
23
  [project.urls]
21
- Homepage = "https://github.com/insightfactory-ai/insightfactory-cli"
22
- Repository = "https://github.com/insightfactory-ai/insightfactory-cli"
24
+ Homepage = "https://insightfactory.ai"
23
25
 
24
26
  [build-system]
25
27
  requires = ["hatchling"]
@@ -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 fetch_with_timeout, parse_url, url_href, url_origin
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(url, method=method, headers=headers, body=values["data"])
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,4 +1,5 @@
1
1
  ENV_PROFILE: str = "INSIGHTFACTORY_CONFIG_PROFILE"
2
+ ENV_REQUEST_TIMEOUT: str = "INSIGHTFACTORY_REQUEST_TIMEOUT"
2
3
  DEFAULT_SCOPE: str = "openid profile email offline_access"
3
4
  LOOPBACK_HOST: str = "127.0.0.1"
4
5
  DEFAULT_CALLBACK_PORT: int = 53682
@@ -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
- REQUEST_TIMEOUT_MS = 30_000
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,13 @@ 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=REQUEST_TIMEOUT_MS / 1000) as response:
191
+ # Callers pass endpoints validated by parse_http_url or derived from a
192
+ # validated factory origin. file: and other local schemes are rejected.
193
+ with urllib.request.urlopen( # nosec B310
194
+ request, timeout=effective_timeout
195
+ ) as response:
142
196
  return HttpResponse(response.status, response.headers, response.read())
143
197
  except urllib.error.HTTPError as error:
144
198
  return HttpResponse(error.code, error.headers, error.read())
@@ -147,7 +201,7 @@ def fetch_with_timeout(
147
201
  if isinstance(reason, (TimeoutError, socket.timeout)) or getattr(reason, "errno", None) == getattr(
148
202
  socket, "ETIMEDOUT", None
149
203
  ):
150
- die(f"request timed out after {REQUEST_TIMEOUT_MS / 1000}s: {url}")
204
+ _die_on_timeout(url, method, effective_timeout, warn_unsafe_retry=warn_unsafe_retry)
151
205
  raise
152
206
  except TimeoutError:
153
- die(f"request timed out after {REQUEST_TIMEOUT_MS / 1000}s: {url}")
207
+ _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
 
@@ -4,7 +4,7 @@ import base64
4
4
  import hashlib
5
5
  import json
6
6
  import secrets
7
- import subprocess
7
+ import subprocess # nosec B404
8
8
  import sys
9
9
  import time
10
10
  from http.server import BaseHTTPRequestHandler, HTTPServer
@@ -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
@@ -51,7 +51,8 @@ def open_browser(url: str, launcher: str | None = None) -> None:
51
51
  else:
52
52
  command, args = "xdg-open", [url]
53
53
  try:
54
- subprocess.Popen(
54
+ # The executable is selected by platform and shell remains false.
55
+ subprocess.Popen( # nosec B603
55
56
  [command, *args],
56
57
  stdout=subprocess.DEVNULL,
57
58
  stderr=subprocess.DEVNULL,
@@ -66,7 +67,10 @@ def build_callback_url(port: int) -> str:
66
67
 
67
68
 
68
69
  def _discover(host: str) -> dict[str, Any]:
69
- response = fetch_with_timeout(f"{host}/.well-known/oauth-authorization-server")
70
+ response = fetch_with_timeout(
71
+ f"{host}/.well-known/oauth-authorization-server",
72
+ timeout=DEFAULT_REQUEST_TIMEOUT_S,
73
+ )
70
74
  if not response.ok:
71
75
  die(f"OAuth discovery failed for {host} (HTTP {response.status})")
72
76
  try:
@@ -114,11 +118,15 @@ def _parse_expires_in(value: object) -> float | None:
114
118
 
115
119
 
116
120
  def _token_request(token_endpoint: str, form: dict[str, str]) -> dict[str, object]:
121
+ # Pinned to the default rather than the caller's timeout: get_valid_token is a
122
+ # documented in-process surface that promises a bounded wait, and an OAuth round
123
+ # trip has no reason to inherit a ceiling chosen for a slow factory endpoint.
117
124
  response = fetch_with_timeout(
118
125
  token_endpoint,
119
126
  method="POST",
120
127
  headers={"Content-Type": "application/x-www-form-urlencoded"},
121
128
  body=urlencode(form),
129
+ timeout=DEFAULT_REQUEST_TIMEOUT_S,
122
130
  )
123
131
  if not response.ok:
124
132
  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
- assert describe_after == describe_before == {"profile": "foundry-dev", "method": None, "data": None}
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
 
@@ -34,7 +34,7 @@ wheels = [
34
34
 
35
35
  [[package]]
36
36
  name = "insightfactory-cli"
37
- version = "1.0.1"
37
+ version = "1.0.2"
38
38
  source = { editable = "." }
39
39
 
40
40
  [package.dev-dependencies]