insightfactory-cli 1.0.0.dev1__tar.gz → 1.0.0.dev4__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.0.dev1 → insightfactory_cli-1.0.0.dev4}/.github/workflows/claude.yml +6 -4
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/PKG-INFO +1 -1
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/pyproject.toml +1 -1
- insightfactory_cli-1.0.0.dev4/src/if_cli/cli.py +163 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_api_command.py +58 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_cli.py +143 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_config_command.py +18 -0
- insightfactory_cli-1.0.0.dev1/src/if_cli/cli.py +0 -68
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/.github/workflows/ci.yml +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/.github/workflows/release.yml +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/.gitignore +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/.python-version +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/AGENTS.md +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/CLAUDE.md +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/README.md +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/__init__.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/__main__.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/cache.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/colour.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/__init__.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/api.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/config.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/login.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/logout.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/profiles.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/set_token.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/token.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/config.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/constants.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/http.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/main.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/oauth.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/runtime.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/__init__.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/cache_writer.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/conftest.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/helpers.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/servers.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_api.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_cache.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_config.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_login.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_oauth.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_oauth_flow.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_profiles.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_programmatic_api.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_runtime.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_set_token.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_token.py +0 -0
- {insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/uv.lock +0 -0
{insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/.github/workflows/claude.yml
RENAMED
|
@@ -16,10 +16,12 @@ on:
|
|
|
16
16
|
pull_request_review_comment:
|
|
17
17
|
types: [created]
|
|
18
18
|
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
# No `permissions:` block here on purpose. A called workflow may only reduce the
|
|
20
|
+
# caller's grant, never raise it, and GitHub validates that at startup -- so
|
|
21
|
+
# capping this to `contents: read` made every run fail with `startup_failure`
|
|
22
|
+
# before any job ran. if_s_claude_review.yml declares the scopes it needs
|
|
23
|
+
# (id-token/pull-requests/issues write, actions read); leaving the caller
|
|
24
|
+
# unrestricted is what every other repo in the org that calls it does.
|
|
23
25
|
|
|
24
26
|
jobs:
|
|
25
27
|
claude:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: insightfactory-cli
|
|
3
|
-
Version: 1.0.0.
|
|
3
|
+
Version: 1.0.0.dev4
|
|
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
|
|
@@ -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),
|
{insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_config_command.py
RENAMED
|
@@ -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())
|
|
File without changes
|
{insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/.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.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/config.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/login.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/logout.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/profiles.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/src/if_cli/commands/set_token.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.0.dev1 → insightfactory_cli-1.0.0.dev4}/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
|
|
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.0.dev1 → insightfactory_cli-1.0.0.dev4}/tests/test_programmatic_api.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|