fp-cloud-cli 0.0.1b1__py3-none-any.whl
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.
- fp_cli/__init__.py +10 -0
- fp_cli/__main__.py +4 -0
- fp_cli/_click_compat.py +64 -0
- fp_cli/_context.py +332 -0
- fp_cli/_version.py +1 -0
- fp_cli/analytics.py +432 -0
- fp_cli/analytics_config.py +77 -0
- fp_cli/analytics_registry.py +83 -0
- fp_cli/app.py +492 -0
- fp_cli/auth.py +160 -0
- fp_cli/client.py +1694 -0
- fp_cli/commands/__init__.py +0 -0
- fp_cli/commands/_write.py +214 -0
- fp_cli/commands/agent_cmds.py +407 -0
- fp_cli/commands/alerts_cmds.py +445 -0
- fp_cli/commands/audits_cmds.py +1054 -0
- fp_cli/commands/auth_cmds.py +512 -0
- fp_cli/commands/errors_cmds.py +190 -0
- fp_cli/commands/evals_cmds.py +161 -0
- fp_cli/commands/events_cmds.py +159 -0
- fp_cli/commands/fleet_cmds.py +416 -0
- fp_cli/commands/guardrails_cmds.py +148 -0
- fp_cli/commands/incidents_cmds.py +472 -0
- fp_cli/commands/keys_cmds.py +407 -0
- fp_cli/commands/list_cmds.py +63 -0
- fp_cli/commands/orgs_cmds.py +319 -0
- fp_cli/commands/policies_cmds.py +499 -0
- fp_cli/commands/queries_cmds.py +378 -0
- fp_cli/commands/sessions_cmds.py +151 -0
- fp_cli/commands/settings_cmds.py +150 -0
- fp_cli/commands/usage_cmds.py +35 -0
- fp_cli/commands/users_cmds.py +404 -0
- fp_cli/config.py +330 -0
- fp_cli/dates.py +78 -0
- fp_cli/enforcement.py +345 -0
- fp_cli/errors.py +98 -0
- fp_cli/models.py +891 -0
- fp_cli/orgs.py +30 -0
- fp_cli/output.py +6593 -0
- fp_cli/permissions.py +208 -0
- fp_cli/policy_check.py +290 -0
- fp_cli/py.typed +0 -0
- fp_cli/select.py +322 -0
- fp_cli/theme.py +53 -0
- fp_cloud_cli-0.0.1b1.dist-info/METADATA +335 -0
- fp_cloud_cli-0.0.1b1.dist-info/RECORD +50 -0
- fp_cloud_cli-0.0.1b1.dist-info/WHEEL +5 -0
- fp_cloud_cli-0.0.1b1.dist-info/entry_points.txt +2 -0
- fp_cloud_cli-0.0.1b1.dist-info/licenses/LICENSE +42 -0
- fp_cloud_cli-0.0.1b1.dist-info/top_level.txt +1 -0
fp_cli/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""FailproofAI Cloud CLI — a command-line client for the FailproofAI Cloud API.
|
|
2
|
+
|
|
3
|
+
The query layer lives in :mod:`fp_cli.client` as pure functions that take a
|
|
4
|
+
``ClientContext`` and return plain dataclasses. They never print and never import
|
|
5
|
+
Typer/Rich, so a future MCP server can wrap them with zero duplication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
fp_cli/__main__.py
ADDED
fp_cli/_click_compat.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The Click that Typer is actually running. Import Click through here, never directly.
|
|
2
|
+
|
|
3
|
+
Typer 0.26 vendored Click into ``typer._click`` and dropped its dependency on the pip
|
|
4
|
+
``click`` distribution. The vendored classes are *different class objects* from pip
|
|
5
|
+
Click's, so every place we hand Click an object, or ask Click about one, breaks when
|
|
6
|
+
the two disagree — and each break is silent, because the code still imports, still
|
|
7
|
+
compiles, and every happy path still passes:
|
|
8
|
+
|
|
9
|
+
* ``ClickException`` — Typer's command runner catches only *its* Click's exception
|
|
10
|
+
class. Errors subclassing pip Click's escape **uncaught**: a typed failure that
|
|
11
|
+
should print ``✗ …`` and exit 5 exits **1 with an empty stderr**, and the
|
|
12
|
+
``rich_format_error`` hook in ``app.py`` (reached only *after* that catch) never
|
|
13
|
+
runs. Same for the ``UsageError``/``BadParameter`` we raise by hand.
|
|
14
|
+
* ``Abort`` — ``typer.prompt`` raises its own Click's ``Abort`` on closed stdin, so a
|
|
15
|
+
pip-Click ``except click.Abort`` stops matching and the clean "no TTY, pass a slug"
|
|
16
|
+
usage error becomes a bare abort.
|
|
17
|
+
* Options — Typer 0.26+ has no ``Option`` class in its vendored Click *at all*: every
|
|
18
|
+
option in a Typer-built tree is a ``typer.core.TyperOption``, subclassing
|
|
19
|
+
``Parameter`` directly. ``isinstance(param, click.Option)`` is then quietly always
|
|
20
|
+
False, which emptied the telemetry flag catalog (``analytics_registry``) while its
|
|
21
|
+
own anti-drift test stayed green — the test asked the same broken question.
|
|
22
|
+
|
|
23
|
+
So: resolve the Click that Typer imported, and speak that one.
|
|
24
|
+
``tests/test_click_compat.py`` asserts the package never imports ``click`` directly
|
|
25
|
+
again, and that our errors are still the class Typer catches.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
# typer >= 0.26. pip `click` may well still be installed — it is simply not the
|
|
32
|
+
# Click in play, so binding to it here would reintroduce the whole class of bug.
|
|
33
|
+
from typer._click import ClickException, Command, Parameter
|
|
34
|
+
from typer._click.exceptions import Abort, BadParameter, UsageError
|
|
35
|
+
except ImportError: # typer < 0.26 drives the pip `click` distribution directly
|
|
36
|
+
from click import ( # type: ignore[assignment]
|
|
37
|
+
Abort,
|
|
38
|
+
BadParameter,
|
|
39
|
+
ClickException,
|
|
40
|
+
Command,
|
|
41
|
+
Parameter,
|
|
42
|
+
UsageError,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"Abort",
|
|
47
|
+
"BadParameter",
|
|
48
|
+
"ClickException",
|
|
49
|
+
"Command",
|
|
50
|
+
"Parameter",
|
|
51
|
+
"UsageError",
|
|
52
|
+
"is_option",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_option(param: Parameter) -> bool:
|
|
57
|
+
"""True if ``param`` is an option (``--flag``), not a positional argument.
|
|
58
|
+
|
|
59
|
+
Class identity cannot answer this across both Clicks — the vendored one has no
|
|
60
|
+
``Option`` class to test against — so read the ``param_type_name`` that Click sets
|
|
61
|
+
on every parameter (``"option"`` / ``"argument"``). That holds for pip Click's
|
|
62
|
+
``Option`` and for ``TyperOption`` alike.
|
|
63
|
+
"""
|
|
64
|
+
return getattr(param, "param_type_name", None) == "option"
|
fp_cli/_context.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Shared command-layer state and helpers.
|
|
2
|
+
|
|
3
|
+
Kept separate from ``app.py`` so command modules can import these without a
|
|
4
|
+
circular dependency (``app.py`` imports the command modules).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import List, Optional, Sequence, Tuple
|
|
14
|
+
|
|
15
|
+
from . import _click_compat as click # the Click Typer is running; see _click_compat
|
|
16
|
+
from . import config as cfgmod
|
|
17
|
+
from . import dates as _dates
|
|
18
|
+
|
|
19
|
+
# `AuthMode` is DEFINED in `client.py`, not here, even though it is this layer that
|
|
20
|
+
# resolves it: the import runs `_context` -> `client` (below), and `client` needs the
|
|
21
|
+
# enum at runtime for its bearer-vs-cookie branch — defining it here would make that
|
|
22
|
+
# a cycle. Re-exported so `from ._context import AuthMode` reads naturally beside
|
|
23
|
+
# AppState, which is where most callers want it.
|
|
24
|
+
from .client import AuthMode, ClientContext
|
|
25
|
+
from .errors import AuthError, KeyModeUnsupportedError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AppState:
|
|
30
|
+
json: bool
|
|
31
|
+
base_url: Optional[str]
|
|
32
|
+
token: Optional[str]
|
|
33
|
+
timeout: float
|
|
34
|
+
config: cfgmod.CliConfig
|
|
35
|
+
insecure: bool = False
|
|
36
|
+
org: Optional[str] = None # active tenant slug (flag > env > config)
|
|
37
|
+
# The EXPLICITLY-supplied tenant (global `--org` or `FP_ORG`), before
|
|
38
|
+
# the saved-config fallback. `login` uses this so a *saved* tenant never
|
|
39
|
+
# silently bypasses the interactive org picker — only an explicit choice does.
|
|
40
|
+
# Key mode reuses it as the ONLY org it will ever send (see `build_context`).
|
|
41
|
+
org_explicit: Optional[str] = None
|
|
42
|
+
# The bearer credential in key mode. NEVER written to `cli.json` — see
|
|
43
|
+
# `resolve_auth`.
|
|
44
|
+
api_key: Optional[str] = None
|
|
45
|
+
# Which credential this invocation carries. Defaults to NONE so an AppState
|
|
46
|
+
# built by another path (tests, embedders) is never silently treated as key
|
|
47
|
+
# mode; the transport reads it directly, and everything that is not API_KEY
|
|
48
|
+
# takes the cookie path.
|
|
49
|
+
auth_mode: AuthMode = AuthMode.NONE
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_auth(
|
|
53
|
+
*,
|
|
54
|
+
api_key: Optional[str],
|
|
55
|
+
api_key_on_cli: bool,
|
|
56
|
+
token: Optional[str],
|
|
57
|
+
token_on_cli: bool,
|
|
58
|
+
saved_token: Optional[str],
|
|
59
|
+
) -> Tuple[AuthMode, Optional[str], Optional[str]]:
|
|
60
|
+
"""Resolve the one credential this invocation uses → ``(mode, api_key, token)``.
|
|
61
|
+
|
|
62
|
+
Precedence, highest first::
|
|
63
|
+
|
|
64
|
+
--api-key AND --token -> usage error, exit 2 (never guess)
|
|
65
|
+
--api-key -> key mode
|
|
66
|
+
--token -> session mode
|
|
67
|
+
FP_API_KEY -> key mode (a key env var beats a token env var)
|
|
68
|
+
FP_TOKEN -> session mode
|
|
69
|
+
cli.json session_token -> session mode
|
|
70
|
+
nothing -> AuthMode.NONE (require_auth then exits 4)
|
|
71
|
+
|
|
72
|
+
``*_on_cli`` distinguishes a flag from its env var, which the *values* cannot:
|
|
73
|
+
an explicit ``--token`` has to beat ``FP_API_KEY`` while
|
|
74
|
+
``FP_API_KEY`` beats ``FP_TOKEN``. Click's
|
|
75
|
+
``ctx.get_parameter_source`` is the only thing that knows the difference.
|
|
76
|
+
|
|
77
|
+
``--api-key ""`` (an unset CI variable spelled out) means "no override": the mode
|
|
78
|
+
stays KEY with an empty credential, so `require_auth` raises instead of quietly
|
|
79
|
+
acting as whichever human is logged in on this machine. That mirrors the
|
|
80
|
+
established ``--token ""`` rule exactly.
|
|
81
|
+
|
|
82
|
+
The key is returned for this process only and is never persisted: a session token
|
|
83
|
+
expires in ~24h, which bounds the blast radius of a leaked `cli.json`; an API key
|
|
84
|
+
is valid until someone revokes it. There is also no honest revocation path from
|
|
85
|
+
here — `keys disable` needs `keys:disable`, which a scoped CI key will not hold —
|
|
86
|
+
so a "clear the saved key" command could not actually revoke anything.
|
|
87
|
+
"""
|
|
88
|
+
if api_key_on_cli and token_on_cli:
|
|
89
|
+
raise click.UsageError(
|
|
90
|
+
"--api-key and --token are mutually exclusive: --api-key authenticates as an "
|
|
91
|
+
"API key against /v1, --token as a signed-in user session. Pass exactly one."
|
|
92
|
+
)
|
|
93
|
+
if api_key_on_cli:
|
|
94
|
+
return AuthMode.API_KEY, api_key, None
|
|
95
|
+
if token_on_cli:
|
|
96
|
+
return AuthMode.SESSION, None, token
|
|
97
|
+
# Neither flag was given: whatever Click resolved came from the environment.
|
|
98
|
+
# (Click treats an empty env var as unset, so `FP_API_KEY=""` falls
|
|
99
|
+
# through to the next rung rather than becoming an empty-credential key mode.)
|
|
100
|
+
if api_key is not None:
|
|
101
|
+
return AuthMode.API_KEY, api_key, None
|
|
102
|
+
if token is not None:
|
|
103
|
+
return AuthMode.SESSION, None, token
|
|
104
|
+
if saved_token:
|
|
105
|
+
return AuthMode.SESSION, None, saved_token
|
|
106
|
+
return AuthMode.NONE, None, None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def deny_in_key_mode(state: "AppState", command: str, reason: str) -> None:
|
|
110
|
+
"""Fail ``command`` with exit 2 when this invocation carries an API key.
|
|
111
|
+
|
|
112
|
+
Called as the FIRST statement of every command an API key cannot perform, so the
|
|
113
|
+
CLI never opens a connection it already knows will fail — a 401/403 from the
|
|
114
|
+
server would be a much worse explanation than the real one.
|
|
115
|
+
"""
|
|
116
|
+
if state.auth_mode is AuthMode.API_KEY:
|
|
117
|
+
raise KeyModeUnsupportedError(
|
|
118
|
+
f"`fp {command}` does not work with an API key — {reason}.",
|
|
119
|
+
hint="drop --api-key / FP_API_KEY and sign in with fp login",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolved_base_url(state: AppState) -> str:
|
|
124
|
+
"""The effective dashboard URL.
|
|
125
|
+
|
|
126
|
+
The app callback already resolves flag/env > saved config > the public
|
|
127
|
+
default (`config.DEFAULT_BASE_URL`), so `state.base_url` is normally set.
|
|
128
|
+
This falls back to the same default for any AppState built by another path
|
|
129
|
+
(tests, embedders), so the CLI always has a URL to talk to.
|
|
130
|
+
"""
|
|
131
|
+
return state.base_url or cfgmod.DEFAULT_BASE_URL
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _org_header(state: AppState) -> Optional[str]:
|
|
135
|
+
"""The tenant slug to send as ``X-AgentEye-Org``.
|
|
136
|
+
|
|
137
|
+
In KEY mode only an EXPLICIT ``--org`` / ``FP_ORG`` is ever sent. The saved
|
|
138
|
+
`cli.json` org belongs to whichever human logged in on this machine and has no
|
|
139
|
+
bearing on which org a CI key was minted for; sending it would silently ask for
|
|
140
|
+
another tenant's data and get a 403 that reads like a missing permission.
|
|
141
|
+
|
|
142
|
+
(The reverse trap is real too and `whoami` is the pre-flight for it: an
|
|
143
|
+
instance-scoped key with no `--org` resolves server-side to the DEFAULT org and
|
|
144
|
+
answers with that org's data, no error anywhere.)
|
|
145
|
+
"""
|
|
146
|
+
if state.auth_mode is AuthMode.API_KEY:
|
|
147
|
+
return state.org_explicit
|
|
148
|
+
return state.org
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_context(state: AppState) -> ClientContext:
|
|
152
|
+
return ClientContext(
|
|
153
|
+
base_url=resolved_base_url(state),
|
|
154
|
+
token=state.token,
|
|
155
|
+
timeout=state.timeout,
|
|
156
|
+
verify=not state.insecure,
|
|
157
|
+
org=_org_header(state),
|
|
158
|
+
api_key=state.api_key,
|
|
159
|
+
auth_mode=state.auth_mode,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def require_auth(state: AppState) -> ClientContext:
|
|
164
|
+
"""Return a client context, or raise if no URL is set / not authenticated."""
|
|
165
|
+
base = resolved_base_url(state) # the URL is the prerequisite — check it first
|
|
166
|
+
if state.auth_mode is AuthMode.API_KEY:
|
|
167
|
+
# `--api-key ""` lands here: key mode, no credential. It must NOT fall back
|
|
168
|
+
# to a saved cookie session (see `resolve_auth`), so this is an auth failure.
|
|
169
|
+
if not state.api_key:
|
|
170
|
+
raise AuthError(
|
|
171
|
+
"No API key supplied. Pass --api-key <key> or set FP_API_KEY."
|
|
172
|
+
)
|
|
173
|
+
# No local expiry check: an API key carries no expiry the CLI can see, and the
|
|
174
|
+
# server is the only authority on whether it is still live.
|
|
175
|
+
return ClientContext(
|
|
176
|
+
base_url=base,
|
|
177
|
+
api_key=state.api_key,
|
|
178
|
+
auth_mode=AuthMode.API_KEY,
|
|
179
|
+
timeout=state.timeout,
|
|
180
|
+
verify=not state.insecure,
|
|
181
|
+
org=_org_header(state),
|
|
182
|
+
)
|
|
183
|
+
if not state.token:
|
|
184
|
+
# A pre-move file with no usable session in it (expired, or only ever
|
|
185
|
+
# held a base_url) reaches here after adoption declined to carry
|
|
186
|
+
# anything. Name it, so the upgrade is not blamed for the logout.
|
|
187
|
+
if cfgmod.legacy_install_detected():
|
|
188
|
+
raise AuthError(
|
|
189
|
+
"Not logged in. Run fp login.\n"
|
|
190
|
+
f"The config moved to {cfgmod.config_path()}; "
|
|
191
|
+
f"{cfgmod.legacy_config_path()} held no usable session to carry "
|
|
192
|
+
"over. It is left in place — remove it when convenient."
|
|
193
|
+
)
|
|
194
|
+
raise AuthError("Not logged in. Run fp login.")
|
|
195
|
+
# Only enforce local expiry when the token came from the stored config; an
|
|
196
|
+
# explicit --token / env override has no known expiry, so trust it.
|
|
197
|
+
if state.token == state.config.session_token and cfgmod.is_expired(state.config):
|
|
198
|
+
raise AuthError("Session expired. Run fp login.")
|
|
199
|
+
return ClientContext(
|
|
200
|
+
base_url=base,
|
|
201
|
+
token=state.token,
|
|
202
|
+
timeout=state.timeout,
|
|
203
|
+
verify=not state.insecure,
|
|
204
|
+
org=_org_header(state),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def resolve_dates(
|
|
209
|
+
since: Optional[str], ts_from: Optional[str], ts_to: Optional[str]
|
|
210
|
+
) -> Tuple[Optional[str], Optional[str]]:
|
|
211
|
+
try:
|
|
212
|
+
return _dates.resolve_range(since, ts_from, ts_to)
|
|
213
|
+
except ValueError as exc:
|
|
214
|
+
raise click.BadParameter(str(exc))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def resolve_fields(raw: Optional[str], model_cls: type) -> Optional[List[str]]:
|
|
218
|
+
"""Parse a ``--fields`` CSV into a validated list of model field names (or None).
|
|
219
|
+
|
|
220
|
+
Field names must match the dataclass (and thus the ``--json``) keys of the
|
|
221
|
+
model; unknown names raise a usage error listing the valid set.
|
|
222
|
+
"""
|
|
223
|
+
if not raw:
|
|
224
|
+
return None
|
|
225
|
+
valid = [f.name for f in dataclasses.fields(model_cls)]
|
|
226
|
+
chosen = [f.strip() for f in raw.split(",") if f.strip()]
|
|
227
|
+
unknown = [f for f in chosen if f not in valid]
|
|
228
|
+
if unknown:
|
|
229
|
+
raise click.BadParameter(
|
|
230
|
+
f"unknown field(s): {', '.join(unknown)}. Valid fields: {', '.join(valid)}"
|
|
231
|
+
)
|
|
232
|
+
return chosen
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def collect_multi(values: Optional[Sequence[str]]) -> Optional[List[str]]:
|
|
236
|
+
"""Normalize a repeatable + comma-separated CLI option into one flat, de-duplicated list.
|
|
237
|
+
|
|
238
|
+
The single reusable helper behind every multi-value filter. Typer hands us a list with
|
|
239
|
+
one entry per repeated flag (``--env prod --env staging`` → ``["prod", "staging"]``), and
|
|
240
|
+
each entry may itself be a comma-separated group (``--env prod,staging`` → ``["prod,staging"]``).
|
|
241
|
+
This splits every entry on commas, trims surrounding whitespace (so ``--env "prod, staging"``
|
|
242
|
+
works), drops empties (so a trailing comma ``--env prod,`` adds nothing), and de-duplicates
|
|
243
|
+
while preserving first-seen order. Returns ``None`` when nothing usable remains, so an unset
|
|
244
|
+
or blank option stays ``None`` and the client drops the param (unchanged single-value path).
|
|
245
|
+
|
|
246
|
+
A single value still yields the obvious one-item list (``--env prod`` → ``["prod"]``), which
|
|
247
|
+
the client serializes back to a bare ``environment=prod`` — fully backward compatible.
|
|
248
|
+
"""
|
|
249
|
+
if not values:
|
|
250
|
+
return None
|
|
251
|
+
out: List[str] = []
|
|
252
|
+
seen: set = set()
|
|
253
|
+
for raw in values:
|
|
254
|
+
for part in str(raw).split(","):
|
|
255
|
+
v = part.strip()
|
|
256
|
+
if v and v not in seen:
|
|
257
|
+
seen.add(v)
|
|
258
|
+
out.append(v)
|
|
259
|
+
return out or None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def validate_choice(
|
|
263
|
+
value: Optional[str], allowed: Sequence[str], *, flag: str
|
|
264
|
+
) -> Optional[str]:
|
|
265
|
+
"""Return ``value`` if it's None or one of ``allowed``; else a usage error (exit 2).
|
|
266
|
+
|
|
267
|
+
Keeps enum-style flags (``--status``) failing fast client-side with a clear
|
|
268
|
+
message, consistent with ``--order``/``--source``/``--kind`` — rather than the
|
|
269
|
+
server's opaque HTTP 400 (exit 1).
|
|
270
|
+
"""
|
|
271
|
+
if value is None or value in allowed:
|
|
272
|
+
return value
|
|
273
|
+
raise click.BadParameter(
|
|
274
|
+
f"'{value}' is not valid for {flag}. Choose one of: {', '.join(allowed)}.",
|
|
275
|
+
param_hint=flag,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def validate_limit(limit: Optional[int], *, flag: str = "--limit") -> None:
|
|
280
|
+
"""Reject a non-positive row limit up front with a clean usage error (exit 2), rather
|
|
281
|
+
than passing ``0``/negative through to the server, which silently defaults/clamps it to
|
|
282
|
+
a confusing result. Mirrors ``validate_choice`` / ``validate_score_filters``."""
|
|
283
|
+
if limit is not None and limit <= 0:
|
|
284
|
+
raise click.BadParameter("must be a positive integer (at least 1).", param_hint=flag)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def validate_score_filters(values: Optional[Sequence[str]]) -> None:
|
|
288
|
+
"""Validate ``--score`` values are ``KEY:MIN..MAX`` (either bound optional).
|
|
289
|
+
|
|
290
|
+
Without this a malformed value (no ``:`` or no ``..``) is silently sent and
|
|
291
|
+
dropped server-side, returning the UNFILTERED set — a silent footgun. Raises a
|
|
292
|
+
usage error (exit 2) on a bad value. Accepts e.g. ``helpfulness:0.5..0.8``,
|
|
293
|
+
``x:..0.5``, ``y:0.9..``.
|
|
294
|
+
"""
|
|
295
|
+
for v in values or []:
|
|
296
|
+
key, sep, rng = v.partition(":")
|
|
297
|
+
ok = bool(sep) and bool(key.strip()) and ".." in rng
|
|
298
|
+
if ok:
|
|
299
|
+
lo, _, hi = rng.partition("..")
|
|
300
|
+
# Both bounds empty (`helpfulness:..`) is meaningless: the server silently
|
|
301
|
+
# drops it and returns the UNFILTERED set, so reject it client-side too.
|
|
302
|
+
if lo == "" and hi == "":
|
|
303
|
+
ok = False
|
|
304
|
+
for bound in (lo, hi):
|
|
305
|
+
if bound != "":
|
|
306
|
+
try:
|
|
307
|
+
# `float("nan")`/`float("inf")` succeed but the server silently
|
|
308
|
+
# drops the filter — reject non-finite bounds too.
|
|
309
|
+
if not math.isfinite(float(bound)):
|
|
310
|
+
ok = False
|
|
311
|
+
break
|
|
312
|
+
except ValueError:
|
|
313
|
+
ok = False
|
|
314
|
+
break
|
|
315
|
+
if not ok:
|
|
316
|
+
raise click.BadParameter(
|
|
317
|
+
f"'{v}' is not a valid score filter. Use KEY:MIN..MAX (either bound "
|
|
318
|
+
"optional), e.g. helpfulness:0.5..0.8, tool_efficiency:..0.3, factuality:0.9..",
|
|
319
|
+
param_hint="--score",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# Appended to every subcommand's --help so an agent that jumps straight to
|
|
324
|
+
# `fp <command> -h` still learns the argument order + where the global options go.
|
|
325
|
+
GLOBALS_EPILOG = (
|
|
326
|
+
"Argument order: `fp [GLOBAL OPTIONS] <command> [<subcommand>] [ARGS] [OPTIONS]`. "
|
|
327
|
+
"**Global** options come *before* the command — `--json`, `--base-url`, `--token`, "
|
|
328
|
+
"`--api-key`, `--insecure`/`--secure`, `--timeout`, `--quiet`, `--no-color`. A command's (or "
|
|
329
|
+
"subcommand's) own options come *after* it. "
|
|
330
|
+
"e.g. `fp --json keys create ci-bot --permission-set read-only` — `--json` is global, "
|
|
331
|
+
"`keys` the command, `create` the subcommand, `--permission-set` its option."
|
|
332
|
+
)
|
fp_cli/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1b1"
|