insightfactory-cli 1.0.0.dev1__tar.gz → 1.0.0.dev2__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.0.dev1 → insightfactory_cli-1.0.0.dev2}/PKG-INFO +1 -1
  2. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/pyproject.toml +1 -1
  3. insightfactory_cli-1.0.0.dev2/src/if_cli/cli.py +163 -0
  4. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_api_command.py +58 -0
  5. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_cli.py +143 -0
  6. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_config_command.py +18 -0
  7. insightfactory_cli-1.0.0.dev1/src/if_cli/cli.py +0 -68
  8. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/.github/workflows/ci.yml +0 -0
  9. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/.github/workflows/claude.yml +0 -0
  10. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/.github/workflows/release.yml +0 -0
  11. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/.gitignore +0 -0
  12. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/.python-version +0 -0
  13. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/AGENTS.md +0 -0
  14. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/CLAUDE.md +0 -0
  15. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/README.md +0 -0
  16. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/__init__.py +0 -0
  17. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/__main__.py +0 -0
  18. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/cache.py +0 -0
  19. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/colour.py +0 -0
  20. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/commands/__init__.py +0 -0
  21. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/commands/api.py +0 -0
  22. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/commands/config.py +0 -0
  23. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/commands/login.py +0 -0
  24. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/commands/logout.py +0 -0
  25. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/commands/profiles.py +0 -0
  26. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/commands/set_token.py +0 -0
  27. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/commands/token.py +0 -0
  28. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/config.py +0 -0
  29. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/constants.py +0 -0
  30. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/http.py +0 -0
  31. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/main.py +0 -0
  32. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/oauth.py +0 -0
  33. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/src/if_cli/runtime.py +0 -0
  34. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/__init__.py +0 -0
  35. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/cache_writer.py +0 -0
  36. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/conftest.py +0 -0
  37. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/helpers.py +0 -0
  38. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/servers.py +0 -0
  39. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_api.py +0 -0
  40. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_cache.py +0 -0
  41. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_config.py +0 -0
  42. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_login.py +0 -0
  43. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_oauth.py +0 -0
  44. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_oauth_flow.py +0 -0
  45. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_profiles.py +0 -0
  46. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_programmatic_api.py +0 -0
  47. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_runtime.py +0 -0
  48. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_set_token.py +0 -0
  49. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/tests/test_token.py +0 -0
  50. {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: insightfactory-cli
3
- Version: 1.0.0.dev1
3
+ Version: 1.0.0.dev2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "insightfactory-cli"
3
- version = "1.0.0.dev1"
3
+ version = "1.0.0.dev2"
4
4
  description = "Profile-based authentication CLI for the InsightFactory Interfaces API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from collections.abc import Callable
6
+ from typing import Any, NoReturn
7
+
8
+ from if_cli.runtime import die, get_exit_code, handle_broken_pipe, install_broken_pipe_handlers
9
+
10
+ Options = dict[str, dict[str, Any]]
11
+
12
+
13
+ class _ArgumentParser(argparse.ArgumentParser):
14
+ def error(self, message: str) -> NoReturn:
15
+ # Node util.parseArgs reports "Unknown option '--flag'". argparse says
16
+ # "unrecognized arguments: --flag"; rewrite the common case so the two
17
+ # CLIs fail with the same wording.
18
+ if message.startswith("unrecognized arguments: "):
19
+ unknown = message.removeprefix("unrecognized arguments: ").split()[0]
20
+ die(f"Unknown option '{unknown}'")
21
+ die(message)
22
+
23
+
24
+ def _option_flag_label(name: str, spec: dict[str, Any]) -> str:
25
+ short = spec.get("short")
26
+ if short:
27
+ return f"--{name}/-{short}"
28
+ return f"--{name}"
29
+
30
+
31
+ def _resolve_short_option(
32
+ arg: str,
33
+ *,
34
+ options: Options,
35
+ short_options: dict[str, str],
36
+ ) -> tuple[str, str | None] | None:
37
+ """Return (option_name, inline_value) for a short-option token, or None if not an option."""
38
+ option_name = short_options.get(arg)
39
+ if option_name is not None:
40
+ return option_name, None
41
+
42
+ for short_flag, name in short_options.items():
43
+ if not arg.startswith(short_flag) or len(arg) <= len(short_flag):
44
+ continue
45
+ if options[name]["type"] == "boolean":
46
+ die(f"Unknown option '{arg}'")
47
+ return name, arg[len(short_flag) :]
48
+
49
+ return None
50
+
51
+
52
+ def _parse_args_with_positionals(argv: list[str], *, options: Options) -> tuple[dict[str, Any], list[str]]:
53
+ """Parse argv allowing options and positionals in any order (Node util.parseArgs parity)."""
54
+ values: dict[str, Any] = {}
55
+ for name, spec in options.items():
56
+ if spec["type"] == "boolean":
57
+ values[name] = spec.get("default", False)
58
+ else:
59
+ values[name] = spec.get("default")
60
+
61
+ long_options = {f"--{name}": name for name in options}
62
+ short_options = {f"-{spec['short']}": name for name, spec in options.items() if spec.get("short") is not None}
63
+
64
+ positionals: list[str] = []
65
+ index = 0
66
+ while index < len(argv):
67
+ arg = argv[index]
68
+ if arg == "--":
69
+ positionals.extend(argv[index + 1 :])
70
+ break
71
+
72
+ option_name: str | None = None
73
+ inline_value: str | None = None
74
+
75
+ if arg.startswith("--"):
76
+ if "=" in arg:
77
+ flag, inline_value = arg.split("=", 1)
78
+ option_name = long_options.get(flag)
79
+ if option_name is None:
80
+ die(f"Unknown option '{flag}'")
81
+ else:
82
+ option_name = long_options.get(arg)
83
+ if option_name is None:
84
+ die(f"Unknown option '{arg}'")
85
+ elif arg.startswith("-") and len(arg) > 1:
86
+ resolved = _resolve_short_option(arg, options=options, short_options=short_options)
87
+ if resolved is None:
88
+ die(f"Unknown option '{arg}'")
89
+ option_name, inline_value = resolved
90
+ else:
91
+ positionals.append(arg)
92
+ index += 1
93
+ continue
94
+
95
+ spec = options[option_name]
96
+ if spec["type"] == "boolean":
97
+ if inline_value is not None:
98
+ die(f"argument {_option_flag_label(option_name, spec)}: does not take an argument")
99
+ values[option_name] = True
100
+ index += 1
101
+ continue
102
+
103
+ value = inline_value
104
+ if value is None:
105
+ if index + 1 >= len(argv):
106
+ die(f"argument {_option_flag_label(option_name, spec)}: expected one argument")
107
+ next_arg = argv[index + 1]
108
+ if next_arg == "--" or next_arg.startswith("-"):
109
+ die(f"argument {_option_flag_label(option_name, spec)}: expected one argument")
110
+ value = next_arg
111
+ index += 2
112
+ else:
113
+ index += 1
114
+
115
+ values[option_name] = value
116
+
117
+ return values, positionals
118
+
119
+
120
+ def parse_args(
121
+ argv: list[str],
122
+ *,
123
+ options: Options,
124
+ allow_positionals: bool = False,
125
+ ) -> tuple[dict[str, Any], list[str]]:
126
+ """Parse argv with argparse, using the same flags as the TypeScript parseArgs specs."""
127
+ if allow_positionals:
128
+ return _parse_args_with_positionals(argv, options=options)
129
+
130
+ parser = _ArgumentParser(add_help=False, allow_abbrev=False)
131
+ for name, spec in options.items():
132
+ flags = [f"--{name}"]
133
+ short = spec.get("short")
134
+ if short:
135
+ flags.append(f"-{short}")
136
+ dest = name.replace("-", "_")
137
+ if spec["type"] == "boolean":
138
+ parser.add_argument(*flags, dest=dest, action="store_true", default=spec.get("default", False))
139
+ else:
140
+ parser.add_argument(*flags, dest=dest, default=spec.get("default"))
141
+ namespace = parser.parse_args(argv)
142
+ raw = vars(namespace)
143
+ values = {name: raw.get(name.replace("-", "_")) for name in options}
144
+ return values, []
145
+
146
+
147
+ def run_with_handlers(run_cli: Callable[[list[str]], None], argv: list[str] | None = None) -> None:
148
+ install_broken_pipe_handlers()
149
+ try:
150
+ run_cli(sys.argv[1:] if argv is None else argv)
151
+ # Force any buffered EPIPE to raise here. Python 3.14 can keep the
152
+ # entire command output in the TextIOWrapper until shutdown, where an
153
+ # unhandled BrokenPipeError becomes exit status 120.
154
+ sys.stdout.flush()
155
+ except BrokenPipeError:
156
+ handle_broken_pipe()
157
+ except Exception as error:
158
+ try:
159
+ sys.stderr.write(f"if-cli: {error}\n")
160
+ except BrokenPipeError:
161
+ handle_broken_pipe()
162
+ raise SystemExit(1) from None
163
+ raise SystemExit(get_exit_code())
@@ -62,6 +62,64 @@ def _write_token(config_dir: str, origin: str) -> None:
62
62
  )
63
63
 
64
64
 
65
+ def test_api_routes_accepts_profile_before_or_after_filter():
66
+ server, _state = _api_server("valid")
67
+ config_dir = create_config(f"[foundry-dev]\nhost = {server.origin}\n")
68
+ try:
69
+ after_profile = run_cli(
70
+ ["api", "routes", "-p", "foundry-dev", "health"],
71
+ env={"INSIGHTFACTORY_CONFIG_DIR": config_dir},
72
+ )
73
+ before_profile = run_cli(
74
+ ["api", "routes", "health", "-p", "foundry-dev"],
75
+ env={"INSIGHTFACTORY_CONFIG_DIR": config_dir},
76
+ )
77
+ assert after_profile.returncode == 0, after_profile.stderr
78
+ assert before_profile.returncode == 0, before_profile.stderr
79
+ assert after_profile.stdout == before_profile.stdout
80
+ assert "GET /api/health" in after_profile.stdout
81
+ finally:
82
+ server.close()
83
+ shutil.rmtree(config_dir, ignore_errors=True)
84
+
85
+
86
+ def test_api_describe_accepts_profile_before_or_after_method_and_path():
87
+ server, _state = _api_server("valid")
88
+ config_dir = create_config(f"[foundry-dev]\nhost = {server.origin}\n")
89
+ try:
90
+ after_profile = run_cli(
91
+ ["api", "describe", "-p", "foundry-dev", "GET", "/api/health"],
92
+ env={"INSIGHTFACTORY_CONFIG_DIR": config_dir},
93
+ )
94
+ before_profile = run_cli(
95
+ ["api", "describe", "GET", "/api/health", "-p", "foundry-dev"],
96
+ env={"INSIGHTFACTORY_CONFIG_DIR": config_dir},
97
+ )
98
+ assert after_profile.returncode == 0, after_profile.stderr
99
+ assert before_profile.returncode == 0, before_profile.stderr
100
+ assert after_profile.stdout == before_profile.stdout
101
+ assert '"method": "GET"' in after_profile.stdout
102
+ assert '"/api/health"' in after_profile.stdout
103
+ finally:
104
+ server.close()
105
+ shutil.rmtree(config_dir, ignore_errors=True)
106
+
107
+
108
+ def test_api_routes_treats_tokens_after_double_dash_as_filter_text():
109
+ server, _state = _api_server("valid")
110
+ config_dir = create_config(f"[DEFAULT]\nhost = {server.origin}\n")
111
+ try:
112
+ result = run_cli(
113
+ ["api", "routes", "--", "-p", "ignored"],
114
+ env={"INSIGHTFACTORY_CONFIG_DIR": config_dir},
115
+ )
116
+ assert result.returncode == 1
117
+ assert "no API routes matched '-p'" in result.stderr
118
+ finally:
119
+ server.close()
120
+ shutil.rmtree(config_dir, ignore_errors=True)
121
+
122
+
65
123
  def test_api_routes_reports_a_missing_discovery_document():
66
124
  server, _state = _api_server("missing")
67
125
  config_dir = create_config(f"[DEFAULT]\nhost = {server.origin}\n")
@@ -4,6 +4,8 @@ import json
4
4
  import shutil
5
5
  from pathlib import Path
6
6
 
7
+ import pytest
8
+
7
9
  from if_cli.cli import parse_args
8
10
  from if_cli.commands.api import API_OPTIONS
9
11
  from if_cli.commands.config import CONFIG_OPTIONS
@@ -15,6 +17,80 @@ from if_cli.commands.token import TOKEN_OPTIONS
15
17
  from if_cli.runtime import CliError
16
18
  from tests.helpers import create_config, run_cli
17
19
 
20
+ NODE_PARITY_OPTIONS = {
21
+ "profile": {"type": "string", "short": "p"},
22
+ "json": {"type": "boolean", "short": "j"},
23
+ }
24
+
25
+
26
+ def _default_node_parity_values() -> dict[str, object]:
27
+ return {"profile": None, "json": False}
28
+
29
+
30
+ def test_parse_args_matches_node_util_parseargs_short_and_boolean_edge_cases():
31
+ values, positionals = parse_args(["-pprof"], options=NODE_PARITY_OPTIONS, allow_positionals=True)
32
+ assert values == {"profile": "prof", "json": False}
33
+ assert positionals == []
34
+
35
+ values, positionals = parse_args(["-p=prof"], options=NODE_PARITY_OPTIONS, allow_positionals=True)
36
+ assert values == {"profile": "=prof", "json": False}
37
+ assert positionals == []
38
+
39
+ values, positionals = parse_args(["-p", "prof"], options=NODE_PARITY_OPTIONS, allow_positionals=True)
40
+ assert values == {"profile": "prof", "json": False}
41
+ assert positionals == []
42
+
43
+ values, positionals = parse_args(["--profile=prof"], options=NODE_PARITY_OPTIONS, allow_positionals=True)
44
+ assert values == {"profile": "prof", "json": False}
45
+ assert positionals == []
46
+
47
+ values, positionals = parse_args(["--profile", "prof"], options=NODE_PARITY_OPTIONS, allow_positionals=True)
48
+ assert values == {"profile": "prof", "json": False}
49
+ assert positionals == []
50
+
51
+ values, positionals = parse_args(["-j", "x"], options=NODE_PARITY_OPTIONS, allow_positionals=True)
52
+ assert values == {"profile": None, "json": True}
53
+ assert positionals == ["x"]
54
+
55
+ with pytest.raises(CliError, match="Unknown option '-jx'"):
56
+ parse_args(["-jx"], options=NODE_PARITY_OPTIONS, allow_positionals=True)
57
+
58
+ with pytest.raises(CliError, match="argument --json/-j: does not take an argument"):
59
+ parse_args(["--json=false"], options=NODE_PARITY_OPTIONS, allow_positionals=True)
60
+
61
+ values, positionals = parse_args(["--json", "x"], options=NODE_PARITY_OPTIONS, allow_positionals=True)
62
+ assert values == {"profile": None, "json": True}
63
+ assert positionals == ["x"]
64
+
65
+ with pytest.raises(CliError, match="argument --profile/-p: expected one argument"):
66
+ parse_args(["-p", "--json"], options=NODE_PARITY_OPTIONS, allow_positionals=True)
67
+
68
+ values, positionals = parse_args(["--", "-p", "lit"], options=NODE_PARITY_OPTIONS, allow_positionals=True)
69
+ assert values == _default_node_parity_values()
70
+ assert positionals == ["-p", "lit"]
71
+
72
+ values, positionals = parse_args(["-"], options=NODE_PARITY_OPTIONS, allow_positionals=True)
73
+ assert values == _default_node_parity_values()
74
+ assert positionals == ["-"]
75
+
76
+ with pytest.raises(CliError, match="argument --profile/-p: expected one argument"):
77
+ parse_args(["-p"], options=NODE_PARITY_OPTIONS, allow_positionals=True)
78
+
79
+
80
+ def test_parse_args_accepts_attached_profile_short_option_values():
81
+ values, positionals = parse_args(["-pprof"], options=API_OPTIONS, allow_positionals=True)
82
+ assert values["profile"] == "prof"
83
+ assert positionals == []
84
+
85
+ values, positionals = parse_args(["routes", "-pprof", "task"], options=API_OPTIONS, allow_positionals=True)
86
+ assert values["profile"] == "prof"
87
+ assert positionals == ["routes", "task"]
88
+
89
+
90
+ def test_parse_args_rejects_inline_values_on_boolean_flags():
91
+ with pytest.raises(CliError, match="argument --json: does not take an argument"):
92
+ parse_args(["--json=false"], options=PROFILES_OPTIONS, allow_positionals=True)
93
+
18
94
 
19
95
  def test_help_prints_the_command_reference():
20
96
  result = run_cli(["--help"])
@@ -120,6 +196,73 @@ def test_api_parses_routes_and_describe_subcommands():
120
196
  assert describe == ["describe", "GET", "/api/health"]
121
197
 
122
198
 
199
+ def test_parse_args_accepts_positionals_after_options():
200
+ values_after, positionals_after = parse_args(
201
+ ["routes", "-p", "foundry-dev", "task"],
202
+ options=API_OPTIONS,
203
+ allow_positionals=True,
204
+ )
205
+ values_before, positionals_before = parse_args(
206
+ ["routes", "task", "-p", "foundry-dev"],
207
+ options=API_OPTIONS,
208
+ allow_positionals=True,
209
+ )
210
+ assert values_after == values_before == {"profile": "foundry-dev", "method": None, "data": None}
211
+ assert positionals_after == positionals_before == ["routes", "task"]
212
+
213
+ describe_after, pos_after = parse_args(
214
+ ["describe", "-p", "foundry-dev", "GET", "/api/tasks"],
215
+ options=API_OPTIONS,
216
+ allow_positionals=True,
217
+ )
218
+ describe_before, pos_before = parse_args(
219
+ ["describe", "GET", "/api/tasks", "-p", "foundry-dev"],
220
+ options=API_OPTIONS,
221
+ allow_positionals=True,
222
+ )
223
+ assert describe_after == describe_before == {"profile": "foundry-dev", "method": None, "data": None}
224
+ assert pos_after == pos_before == ["describe", "GET", "/api/tasks"]
225
+
226
+ config_after, config_pos_after = parse_args(
227
+ ["get", "-p", "foundry-dev", "host"],
228
+ options=CONFIG_OPTIONS,
229
+ allow_positionals=True,
230
+ )
231
+ config_before, config_pos_before = parse_args(
232
+ ["get", "host", "-p", "foundry-dev"],
233
+ options=CONFIG_OPTIONS,
234
+ allow_positionals=True,
235
+ )
236
+ assert config_after == config_before == {"profile": "foundry-dev"}
237
+ assert config_pos_after == config_pos_before == ["get", "host"]
238
+
239
+
240
+ def test_parse_args_treats_tokens_after_double_dash_as_positionals():
241
+ values, positionals = parse_args(
242
+ ["routes", "-p", "foundry-dev", "--", "-X", "GET"],
243
+ options=API_OPTIONS,
244
+ allow_positionals=True,
245
+ )
246
+ assert values == {"profile": "foundry-dev", "method": None, "data": None}
247
+ assert positionals == ["routes", "-X", "GET"]
248
+
249
+
250
+ def test_parse_args_rejects_unknown_options_with_node_wording():
251
+ try:
252
+ parse_args(["routes", "--not-a-real-flag"], options=API_OPTIONS, allow_positionals=True)
253
+ except CliError as error:
254
+ assert str(error) == "Unknown option '--not-a-real-flag'"
255
+ else:
256
+ raise AssertionError("expected CliError for unknown flag")
257
+
258
+
259
+ def test_help_is_rejected_like_the_node_cli():
260
+ result = run_cli(["help"])
261
+ assert result.returncode == 1
262
+ assert "unknown command 'help'" in result.stderr
263
+ assert "usage: if-cli <command>" in result.stderr
264
+
265
+
123
266
  def test_unknown_flags_fail_for_each_command():
124
267
  specs = [
125
268
  (LOGIN_OPTIONS, False),
@@ -19,6 +19,24 @@ PROFILE = "\n".join(
19
19
  )
20
20
 
21
21
 
22
+ def test_config_get_accepts_profile_before_or_after_key():
23
+ config_dir = create_config(PROFILE)
24
+ try:
25
+ after_profile = run_cli(
26
+ ["config", "get", "-p", "example-dev", "host"],
27
+ env={"INSIGHTFACTORY_CONFIG_DIR": config_dir},
28
+ )
29
+ before_profile = run_cli(
30
+ ["config", "get", "host", "-p", "example-dev"],
31
+ env={"INSIGHTFACTORY_CONFIG_DIR": config_dir},
32
+ )
33
+ assert after_profile.returncode == 0, after_profile.stderr
34
+ assert before_profile.returncode == 0, before_profile.stderr
35
+ assert after_profile.stdout == before_profile.stdout == "https://factory.example\n"
36
+ finally:
37
+ shutil.rmtree(config_dir, ignore_errors=True)
38
+
39
+
22
40
  def test_config_get_falls_back_to_the_derived_audience_and_default_callback_port():
23
41
  config_dir = create_config(PROFILE)
24
42
  try:
@@ -1,68 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import argparse
4
- import sys
5
- from collections.abc import Callable
6
- from typing import Any, NoReturn
7
-
8
- from if_cli.runtime import die, get_exit_code, handle_broken_pipe, install_broken_pipe_handlers
9
-
10
- Options = dict[str, dict[str, Any]]
11
-
12
-
13
- class _ArgumentParser(argparse.ArgumentParser):
14
- def error(self, message: str) -> NoReturn:
15
- # Node util.parseArgs reports "Unknown option '--flag'". argparse says
16
- # "unrecognized arguments: --flag"; rewrite the common case so the two
17
- # CLIs fail with the same wording.
18
- if message.startswith("unrecognized arguments: "):
19
- unknown = message.removeprefix("unrecognized arguments: ").split()[0]
20
- die(f"Unknown option '{unknown}'")
21
- die(message)
22
-
23
-
24
- def parse_args(
25
- argv: list[str],
26
- *,
27
- options: Options,
28
- allow_positionals: bool = False,
29
- ) -> tuple[dict[str, Any], list[str]]:
30
- """Parse argv with argparse, using the same flags as the TypeScript parseArgs specs."""
31
- parser = _ArgumentParser(add_help=False, allow_abbrev=False)
32
- for name, spec in options.items():
33
- flags = [f"--{name}"]
34
- short = spec.get("short")
35
- if short:
36
- flags.append(f"-{short}")
37
- dest = name.replace("-", "_")
38
- if spec["type"] == "boolean":
39
- parser.add_argument(*flags, dest=dest, action="store_true", default=spec.get("default", False))
40
- else:
41
- parser.add_argument(*flags, dest=dest, default=spec.get("default"))
42
- if allow_positionals:
43
- parser.add_argument("positionals", nargs="*")
44
- namespace = parser.parse_args(argv)
45
- raw = vars(namespace)
46
- values = {name: raw.get(name.replace("-", "_")) for name in options}
47
- if allow_positionals:
48
- return values, list(raw.get("positionals") or [])
49
- return values, []
50
-
51
-
52
- def run_with_handlers(run_cli: Callable[[list[str]], None], argv: list[str] | None = None) -> None:
53
- install_broken_pipe_handlers()
54
- try:
55
- run_cli(sys.argv[1:] if argv is None else argv)
56
- # Force any buffered EPIPE to raise here. Python 3.14 can keep the
57
- # entire command output in the TextIOWrapper until shutdown, where an
58
- # unhandled BrokenPipeError becomes exit status 120.
59
- sys.stdout.flush()
60
- except BrokenPipeError:
61
- handle_broken_pipe()
62
- except Exception as error:
63
- try:
64
- sys.stderr.write(f"if-cli: {error}\n")
65
- except BrokenPipeError:
66
- handle_broken_pipe()
67
- raise SystemExit(1) from None
68
- raise SystemExit(get_exit_code())