withfeedback 0.1.0__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.
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.5
2
+ Name: withfeedback
3
+ Version: 0.1.0
4
+ Summary: Command-line client for withfeedback.com — testimonials, feedback moderation, imports, widgets, and NPS
5
+ Project-URL: Homepage, https://withfeedback.com
6
+ Project-URL: Documentation, https://withfeedback.com/api/docs/
7
+ License: MIT
8
+ Keywords: cli,feedback,testimonials,withfeedback
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: click>=8.1
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: keyring>=24
13
+ Description-Content-Type: text/markdown
14
+
15
+ # withfeedback CLI
16
+
17
+ Command-line client for [withfeedback.com](https://withfeedback.com):
18
+ testimonials, feedback moderation, CSV imports, surveys, NPS, widgets, and
19
+ quota usage — from your terminal or CI.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install withfeedback
25
+ # or run without installing:
26
+ uvx withfeedback --help
27
+ ```
28
+
29
+ The console command is `withfeedback`.
30
+
31
+ ## Quick start with a personal access token (PAT)
32
+
33
+ Good for CI and quick scripting:
34
+
35
+ 1. Log in at your withfeedback.com instance and open **Account → API Tokens**
36
+ (`/accounts/tokens/`).
37
+ 2. Create a token with the scopes you need (see the table below).
38
+ 3. Either export it per-shell:
39
+
40
+ ```bash
41
+ export WITHFEEDBACK_TOKEN=spd_abc123...
42
+ withfeedback testimonials list --team <TEAM_ID> --project <PROJECT_ID>
43
+ ```
44
+
45
+ or persist it in the OS keychain:
46
+
47
+ ```bash
48
+ withfeedback login --token spd_abc123...
49
+ ```
50
+
51
+ API access requires a plan that includes the API/CLI/MCP feature (Pro and
52
+ above).
53
+
54
+ ## Login via OAuth2 device flow (interactive)
55
+
56
+ ```bash
57
+ withfeedback login
58
+ ```
59
+
60
+ No flags needed against withfeedback.com — the CLI defaults to the official
61
+ public client id `7yQMsnY2Is2f5tCuwwgItoQu3fRkEX2wnzIRj0Vh` (public by
62
+ design — device-flow clients carry no secret). For a self-hosted instance
63
+ pass your own client:
64
+
65
+ ```bash
66
+ withfeedback login --client-id <CLIENT_ID> # or WITHFEEDBACK_CLIENT_ID
67
+ ```
68
+
69
+ What happens:
70
+
71
+ 1. The CLI calls `POST /o/device-authorization/` and prints a verification
72
+ URL plus a short code (it also tries to open your browser).
73
+ 2. You open the URL, enter the code, and approve the requested scopes.
74
+ 3. The CLI polls `POST /o/token/` until you approve, then stores the access
75
+ and refresh tokens in the **OS keychain** via `keyring` (macOS Keychain,
76
+ Windows Credential Locker, Secret Service/KWallet on Linux) — never in a
77
+ plaintext file.
78
+
79
+ When an access token expires, the CLI transparently refreshes it. Refresh
80
+ tokens rotate on every use; the rotated token replaces the stored one.
81
+ (Stored PATs are never "refreshed" — if a PAT is rejected, the CLI tells you
82
+ to create a new one and run `withfeedback login --token` again.)
83
+
84
+ ```bash
85
+ withfeedback logout # remove stored tokens from the keychain
86
+ ```
87
+
88
+ Token resolution precedence for every command:
89
+ `--token` flag > `WITHFEEDBACK_TOKEN` env var > OS keychain.
90
+
91
+ ## Commands
92
+
93
+ ```bash
94
+ withfeedback login [--client-id <ID> | --token <PAT>]
95
+ withfeedback logout
96
+
97
+ withfeedback testimonials list --team <ID> --project <ID> [--status pending|approved|rejected|spam] [--rating N] [--q text] [--json]
98
+ withfeedback testimonials approve <SUBMISSION_ID> --team <ID> [--json]
99
+ withfeedback testimonials reject <SUBMISSION_ID> --team <ID> [--json]
100
+ withfeedback testimonials create --team <ID> --project <ID> --text "..." [--rating N] [--name ..] [--email ..] [--title ..] [--company ..] [--consent] [--tag t]... [--json]
101
+
102
+ withfeedback import csv FILE --team <ID> --project <ID> --text-col COL [--rating-col COL] [--name-col COL] [--email-col COL] [--consent] [--json]
103
+
104
+ withfeedback surveys list --team <ID> --project <ID> [--json]
105
+ withfeedback nps summary --team <ID> --project <ID> [--json]
106
+ withfeedback widgets list --team <ID> --project <ID> [--json]
107
+ withfeedback usage --team <ID> [--json]
108
+ ```
109
+
110
+ Human output is plain text tables. Pass `--json` for machine-readable output
111
+ (raw API responses) — errors still go to stderr.
112
+
113
+ ### CSV import
114
+
115
+ `withfeedback import csv` validates the file locally (header columns, row
116
+ count), builds the column mapping from your `--*-col` options, and uploads
117
+ the file to the server's multipart import endpoint. Rows are processed
118
+ asynchronously and land as **pending** submissions; the command prints the
119
+ batch id and a status URL to poll.
120
+
121
+ ```bash
122
+ withfeedback import csv reviews.csv \
123
+ --team <TEAM_ID> --project <PROJECT_ID> \
124
+ --text-col "Testimonial" --rating-col "Stars" \
125
+ --name-col "Author" --email-col "Email" --consent
126
+ ```
127
+
128
+ Pass `--consent` only when display consent was actually collected for these
129
+ rows.
130
+
131
+ ## Required scopes per command
132
+
133
+ | Command | Required scope |
134
+ |---|---|
135
+ | `testimonials list` | `read:feedback` (or `read:testimonials`) |
136
+ | `testimonials approve` / `reject` | `moderate:submissions` (`write:*` cannot moderate) |
137
+ | `testimonials create` | `write:testimonials` (or `write:feedback`) |
138
+ | `import csv` | `write:feedback` |
139
+ | `surveys list`, `nps summary` | `read:surveys` |
140
+ | `widgets list` | `read:widgets` |
141
+ | `usage` | `read:feedback` |
142
+
143
+ ## Exit codes
144
+
145
+ | Code | Meaning |
146
+ |---|---|
147
+ | 0 | Success |
148
+ | 1 | General / unknown error |
149
+ | 2 | Usage / config error (bad flags, unreadable CSV, missing column) |
150
+ | 3 | Auth error (not logged in, invalid or expired token) |
151
+ | 4 | Forbidden or payment required (missing scope/role, plan without API access, quota exceeded, billing blocked) |
152
+ | 5 | Not found (unknown team/project/object or no access) |
153
+ | 6 | Validation error or invalid state transition |
154
+ | 7 | Network error (server unreachable) |
155
+
156
+ On API errors the API's error detail is printed to stderr.
157
+
158
+ ## Environment variables
159
+
160
+ | Variable | Default | Purpose |
161
+ |---|---|---|
162
+ | `WITHFEEDBACK_API_URL` | `https://withfeedback.com` | API base URL (self-hosted/staging override) |
163
+ | `WITHFEEDBACK_TOKEN` | — | PAT (beats the keychain, loses to `--token`) |
164
+ | `WITHFEEDBACK_CLIENT_ID` | official public client id | Client id for `login` (override for self-hosted) |
165
+ | `WITHFEEDBACK_SCOPES` | all CLI scopes | Scopes requested during device-flow login |
166
+
167
+ On the first request of a run the CLI checks `GET /.well-known/speedpy.json`
168
+ once and warns (never fails) when the manifest is unreachable or the API is
169
+ older than the minimum this client expects.
170
+
171
+ ## Development
172
+
173
+ ```bash
174
+ uv run --with pytest --with httpx --with click --with keyring pytest packages/withfeedback-cli/
175
+ ```
@@ -0,0 +1,8 @@
1
+ withfeedback_cli/__init__.py,sha256=clnmpqafsocCATdjRtqF8VsYdR2hUmHo0hnWRo05Ns0,59
2
+ withfeedback_cli/auth.py,sha256=oUQPdMKxFblM-Rekr3I5HrFaUvDlqaYTwsd4OiN5Bco,9295
3
+ withfeedback_cli/client.py,sha256=db2T4vEPZUmyJkng3CCmpk40HCIxx2HZBNRvxSfTbzQ,5445
4
+ withfeedback_cli/main.py,sha256=tbSS4EVMhiM2iEWhFHpsWdoU6Y15utk6QzZNp63W-fQ,20957
5
+ withfeedback-0.1.0.dist-info/METADATA,sha256=c-ZT9FZM5kXc06YBSbWfs6DimyetbEJB5wwEnfqO0gU,6295
6
+ withfeedback-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ withfeedback-0.1.0.dist-info/entry_points.txt,sha256=GpkGmOuJYp1tagxxF4n_4wawWiUt-eBntWcgzM-mwbY,59
8
+ withfeedback-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ withfeedback = withfeedback_cli.main:cli
@@ -0,0 +1,3 @@
1
+ """withfeedback.com CLI package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,284 @@
1
+ """Authentication for the withfeedback CLI.
2
+
3
+ Tokens live in the OS keychain via ``keyring`` (macOS Keychain, Windows
4
+ Credential Locker, Secret Service on Linux) — never in plaintext files.
5
+
6
+ Stored entries (service ``withfeedback-cli``):
7
+
8
+ - ``access_token`` — OAuth2 access token or PAT
9
+ - ``refresh_token`` — OAuth2 refresh token (rotation handled on refresh)
10
+ - ``token_kind`` — ``"oauth"`` or ``"pat"``
11
+ - ``client_id`` — OAuth2 client id used for login/refresh
12
+
13
+ Token resolution precedence for API calls:
14
+ ``--token`` flag > ``WITHFEEDBACK_TOKEN`` env > keychain.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import sys
21
+ import time
22
+ import webbrowser
23
+
24
+ import httpx
25
+ import keyring
26
+ import keyring.errors
27
+
28
+ from .client import APIError, TIMEOUT, USER_AGENT, get_base_url
29
+
30
+ KEYRING_SERVICE = "withfeedback-cli"
31
+
32
+ #: Official public device-flow OAuth2 client for withfeedback.com
33
+ #: (public by design — device-flow clients have no secret).
34
+ #: Override with --client-id / WITHFEEDBACK_CLIENT_ID for self-hosted
35
+ #: instances.
36
+ DEFAULT_CLIENT_ID = "7yQMsnY2Is2f5tCuwwgItoQu3fRkEX2wnzIRj0Vh"
37
+
38
+ #: Scopes requested during device-flow login — everything the CLI uses.
39
+ DEFAULT_SCOPES = (
40
+ "read:teams read:feedback write:feedback read:testimonials "
41
+ "write:testimonials moderate:submissions read:widgets read:surveys"
42
+ )
43
+
44
+ _KEYS = ("access_token", "refresh_token", "token_kind", "client_id")
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Keychain storage
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ def store_tokens(
53
+ access_token: str,
54
+ refresh_token: str = "",
55
+ *,
56
+ kind: str = "oauth",
57
+ client_id: str = "",
58
+ ) -> None:
59
+ """Persist tokens to the OS keychain.
60
+
61
+ Writes the new values before deleting anything, so an interruption
62
+ mid-update never leaves the keychain without a usable access token.
63
+ """
64
+ keyring.set_password(KEYRING_SERVICE, "access_token", access_token)
65
+ keyring.set_password(KEYRING_SERVICE, "token_kind", kind)
66
+ if client_id:
67
+ keyring.set_password(KEYRING_SERVICE, "client_id", client_id)
68
+ if refresh_token:
69
+ keyring.set_password(KEYRING_SERVICE, "refresh_token", refresh_token)
70
+ else:
71
+ # Delete last: everything new is already written at this point.
72
+ _delete_entry("refresh_token")
73
+
74
+
75
+ def _read(key: str) -> str:
76
+ try:
77
+ return keyring.get_password(KEYRING_SERVICE, key) or ""
78
+ except Exception:
79
+ return ""
80
+
81
+
82
+ def stored_access_token() -> str:
83
+ return _read("access_token")
84
+
85
+
86
+ def stored_refresh_token() -> str:
87
+ return _read("refresh_token")
88
+
89
+
90
+ def stored_token_kind() -> str:
91
+ return _read("token_kind")
92
+
93
+
94
+ def stored_client_id() -> str:
95
+ return _read("client_id")
96
+
97
+
98
+ def _delete_entry(key: str) -> None:
99
+ """Delete a keychain entry. A missing entry is fine; any other failure
100
+ is logged (the stale credential is still in the keychain)."""
101
+ try:
102
+ keyring.delete_password(KEYRING_SERVICE, key)
103
+ except keyring.errors.PasswordDeleteError:
104
+ pass # entry didn't exist — nothing to delete
105
+ except Exception as exc:
106
+ print(
107
+ f"warning: could not delete keychain entry {key!r}: {exc}. "
108
+ "The stale credential may still be stored — remove it with your "
109
+ "OS keychain tool if needed.",
110
+ file=sys.stderr,
111
+ )
112
+
113
+
114
+ def clear_tokens() -> None:
115
+ for key in _KEYS:
116
+ _delete_entry(key)
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # OAuth2 device flow + refresh-token rotation
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def device_flow(client_id: str, scope: str | None = None) -> dict:
125
+ """Run the device authorization grant; return the full token response
126
+ (``access_token``, ``refresh_token``, ``expires_in``, ...)."""
127
+ base = get_base_url()
128
+ scope = scope or os.environ.get("WITHFEEDBACK_SCOPES", DEFAULT_SCOPES)
129
+ try:
130
+ resp = httpx.post(
131
+ f"{base}/o/device-authorization/",
132
+ data={"client_id": client_id, "scope": scope},
133
+ headers={
134
+ "Content-Type": "application/x-www-form-urlencoded",
135
+ "User-Agent": USER_AGENT,
136
+ },
137
+ timeout=TIMEOUT,
138
+ )
139
+ resp.raise_for_status()
140
+ except httpx.RequestError as exc:
141
+ raise APIError(0, f"Network error contacting {base}: {exc}") from exc
142
+ except httpx.HTTPStatusError as exc:
143
+ raise APIError(
144
+ exc.response.status_code,
145
+ f"Device authorization failed: HTTP {exc.response.status_code}. "
146
+ "Is the client id correct?",
147
+ ) from exc
148
+ data = resp.json()
149
+
150
+ verification_uri = data.get("verification_uri_complete") or data["verification_uri"]
151
+ user_code = data["user_code"]
152
+ device_code = data["device_code"]
153
+ interval = data.get("interval", 5)
154
+ expires_in = data.get("expires_in", 600)
155
+
156
+ print(
157
+ f"\n Open this URL in your browser:\n {verification_uri}\n"
158
+ f"\n Enter code: {user_code}\n",
159
+ file=sys.stderr,
160
+ )
161
+ try:
162
+ webbrowser.open(verification_uri)
163
+ except Exception:
164
+ pass # headless environments
165
+
166
+ # Poll no longer than the device code is valid (RFC 8628 §3.5): the
167
+ # deadline caps the loop, and each sleep is clamped to the time left.
168
+ deadline = time.monotonic() + expires_in
169
+ while True:
170
+ remaining = deadline - time.monotonic()
171
+ if remaining <= 0:
172
+ break
173
+ time.sleep(min(interval, remaining))
174
+ try:
175
+ token_resp = httpx.post(
176
+ f"{base}/o/token/",
177
+ data={
178
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
179
+ "device_code": device_code,
180
+ "client_id": client_id,
181
+ },
182
+ headers={
183
+ "Content-Type": "application/x-www-form-urlencoded",
184
+ "User-Agent": USER_AGENT,
185
+ },
186
+ timeout=TIMEOUT,
187
+ )
188
+ except httpx.HTTPError as exc:
189
+ raise APIError(
190
+ 0,
191
+ f"Network error while waiting for device-flow approval: {exc}. "
192
+ "Check your connection and run 'withfeedback login' again.",
193
+ ) from exc
194
+ if token_resp.status_code == 200:
195
+ print(" Authenticated successfully.\n", file=sys.stderr)
196
+ return token_resp.json()
197
+
198
+ try:
199
+ error = token_resp.json().get("error", "")
200
+ except ValueError:
201
+ raise APIError(
202
+ token_resp.status_code,
203
+ f"Device flow: token endpoint returned HTTP "
204
+ f"{token_resp.status_code}.",
205
+ )
206
+ if error == "authorization_pending":
207
+ continue
208
+ if error == "slow_down":
209
+ # RFC 8628 §3.5: increase the polling interval by 5 seconds.
210
+ interval += 5
211
+ continue
212
+ raise APIError(token_resp.status_code, f"Device flow error: {error}")
213
+
214
+ raise APIError(0, "Device code expired before approval. Run login again.")
215
+
216
+
217
+ def refresh_access_token() -> str:
218
+ """Exchange the stored refresh token for a new pair.
219
+
220
+ Refresh tokens rotate server-side: the response's new refresh token
221
+ replaces the stored one. Returns the new access token, or "" when
222
+ refresh isn't possible (no refresh token / refresh rejected).
223
+ """
224
+ refresh_token = stored_refresh_token()
225
+ client_id = stored_client_id()
226
+ if not refresh_token or not client_id:
227
+ return ""
228
+
229
+ base = get_base_url()
230
+ try:
231
+ resp = httpx.post(
232
+ f"{base}/o/token/",
233
+ data={
234
+ "grant_type": "refresh_token",
235
+ "refresh_token": refresh_token,
236
+ "client_id": client_id,
237
+ },
238
+ headers={
239
+ "Content-Type": "application/x-www-form-urlencoded",
240
+ "User-Agent": USER_AGENT,
241
+ },
242
+ timeout=TIMEOUT,
243
+ )
244
+ except httpx.HTTPError:
245
+ return ""
246
+ if resp.status_code != 200:
247
+ return ""
248
+
249
+ payload = resp.json()
250
+ access_token = payload.get("access_token", "")
251
+ if not access_token:
252
+ return ""
253
+ store_tokens(
254
+ access_token,
255
+ payload.get("refresh_token", ""), # rotated refresh token
256
+ kind="oauth",
257
+ client_id=client_id,
258
+ )
259
+ return access_token
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Token resolution
264
+ # ---------------------------------------------------------------------------
265
+
266
+
267
+ def resolve_token(flag_token: str = "") -> tuple[str, str]:
268
+ """Resolve (token, source) — source is "flag", "env", or "keyring".
269
+
270
+ Raises :class:`APIError` when no credentials exist anywhere.
271
+ """
272
+ if flag_token:
273
+ return flag_token, "flag"
274
+ env_token = os.environ.get("WITHFEEDBACK_TOKEN", "")
275
+ if env_token:
276
+ return env_token, "env"
277
+ stored = stored_access_token()
278
+ if stored:
279
+ return stored, "keyring"
280
+ raise APIError(
281
+ 0,
282
+ "Not authenticated. Run 'withfeedback login' (device flow), "
283
+ "'withfeedback login --token <PAT>', or set WITHFEEDBACK_TOKEN.",
284
+ )
@@ -0,0 +1,175 @@
1
+ """Tiny internal HTTP client for the withfeedback.com API (CLI edition).
2
+
3
+ Responsibilities:
4
+
5
+ - Base URL resolution (``WITHFEEDBACK_API_URL`` env override, default
6
+ ``https://withfeedback.com``).
7
+ - Bearer auth header (token is resolved by :mod:`withfeedback_cli.auth`).
8
+ - 30 second request timeout.
9
+ - One-shot, in-process cached version-compatibility check against
10
+ ``GET /.well-known/speedpy.json`` — warns (never fails) when the
11
+ manifest is unreachable or older than we expect.
12
+ - Readable :class:`APIError` messages carrying the API's error detail.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ from typing import Any
20
+
21
+ import httpx
22
+
23
+ from . import __version__
24
+
25
+ DEFAULT_BASE_URL = "https://withfeedback.com"
26
+ MIN_API_VERSION = "1.0.0"
27
+ USER_AGENT = f"withfeedback-cli/{__version__}"
28
+ TIMEOUT = 30.0
29
+
30
+
31
+ class APIError(Exception):
32
+ """API error with a readable message and the HTTP status (0 = network)."""
33
+
34
+ def __init__(self, status_code: int, message: str):
35
+ self.status_code = status_code
36
+ super().__init__(message)
37
+
38
+
39
+ def get_base_url() -> str:
40
+ """Resolve the API base URL (env override, no trailing slash)."""
41
+ return os.environ.get("WITHFEEDBACK_API_URL", DEFAULT_BASE_URL).rstrip("/")
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Version compatibility (manifest) check — cached in process, warn-only
46
+ # ---------------------------------------------------------------------------
47
+
48
+ _manifest_state: dict[str, Any] = {"checked": False, "manifest": None}
49
+
50
+
51
+ def _parse_version(value: str) -> tuple[int, ...]:
52
+ return tuple(int(part) for part in value.strip().split("."))
53
+
54
+
55
+ def check_compatibility() -> dict | None:
56
+ """Fetch ``/.well-known/speedpy.json`` once per process; warn, never fail."""
57
+ if _manifest_state["checked"]:
58
+ return _manifest_state["manifest"]
59
+ _manifest_state["checked"] = True
60
+
61
+ base = get_base_url()
62
+ try:
63
+ resp = httpx.get(
64
+ f"{base}/.well-known/speedpy.json",
65
+ timeout=TIMEOUT,
66
+ headers={"User-Agent": USER_AGENT},
67
+ )
68
+ resp.raise_for_status()
69
+ manifest = resp.json()
70
+ except Exception:
71
+ print(
72
+ f"warning: could not fetch {base}/.well-known/speedpy.json — "
73
+ "skipping API version compatibility check.",
74
+ file=sys.stderr,
75
+ )
76
+ return None
77
+
78
+ _manifest_state["manifest"] = manifest
79
+ api_version = str(manifest.get("service", {}).get("api_version", ""))
80
+ try:
81
+ if api_version and _parse_version(api_version) < _parse_version(MIN_API_VERSION):
82
+ print(
83
+ f"warning: server API version {api_version} is older than the "
84
+ f"minimum this client expects ({MIN_API_VERSION}); some "
85
+ "commands may not work.",
86
+ file=sys.stderr,
87
+ )
88
+ except ValueError:
89
+ pass
90
+ return manifest
91
+
92
+
93
+ def reset_compatibility_cache() -> None:
94
+ """Testing hook: forget the cached manifest check."""
95
+ _manifest_state["checked"] = False
96
+ _manifest_state["manifest"] = None
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Requests
101
+ # ---------------------------------------------------------------------------
102
+
103
+ _STATUS_HINTS = {
104
+ 400: "Validation error.",
105
+ 401: "Authentication failed — run 'withfeedback login' or check your token.",
106
+ 402: "Payment required — plan quota exceeded or billing blocked.",
107
+ 403: (
108
+ "Forbidden — missing scope, insufficient role, or the team's plan "
109
+ "does not include API access."
110
+ ),
111
+ 404: "Not found — unknown team/project/object, or no access to it.",
112
+ 409: "Conflict — invalid state transition.",
113
+ 429: "Rate limited — slow down and retry later.",
114
+ }
115
+
116
+
117
+ def _error_message(resp: httpx.Response) -> str:
118
+ try:
119
+ body = resp.json()
120
+ except ValueError:
121
+ body = None
122
+
123
+ detail = ""
124
+ if isinstance(body, dict):
125
+ if "detail" in body:
126
+ detail = str(body["detail"])
127
+ if body.get("code"):
128
+ detail = f"[{body['code']}] {detail}"
129
+ else:
130
+ detail = "; ".join(f"{key}: {value}" for key, value in body.items())
131
+
132
+ hint = _STATUS_HINTS.get(resp.status_code, "API error.")
133
+ message = f"HTTP {resp.status_code}: {hint}"
134
+ if detail:
135
+ message = f"{message} {detail}"
136
+ return message
137
+
138
+
139
+ def request(
140
+ method: str,
141
+ path: str,
142
+ token: str,
143
+ *,
144
+ params: dict | None = None,
145
+ json_body: dict | None = None,
146
+ data: dict | None = None,
147
+ files: dict | None = None,
148
+ ) -> Any:
149
+ """Authenticated request (JSON or multipart). Raises :class:`APIError`."""
150
+ check_compatibility()
151
+ url = f"{get_base_url()}{path}"
152
+ try:
153
+ resp = httpx.request(
154
+ method,
155
+ url,
156
+ params=params,
157
+ json=json_body,
158
+ data=data,
159
+ files=files,
160
+ headers={
161
+ "Authorization": f"Bearer {token}",
162
+ "User-Agent": USER_AGENT,
163
+ },
164
+ timeout=TIMEOUT,
165
+ )
166
+ except httpx.HTTPError as exc:
167
+ raise APIError(
168
+ 0, f"Network error contacting {get_base_url()}: {exc}"
169
+ ) from exc
170
+
171
+ if resp.status_code >= 400:
172
+ raise APIError(resp.status_code, _error_message(resp))
173
+ if resp.status_code == 204 or not resp.content:
174
+ return None
175
+ return resp.json()
@@ -0,0 +1,582 @@
1
+ """withfeedback — command-line client for withfeedback.com.
2
+
3
+ Human output is plain text tables; pass ``--json`` on any data command for
4
+ machine-readable output. API errors print the API's error detail to stderr
5
+ and exit non-zero (see exit-code table in the README).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import io
12
+ import json
13
+ import sys
14
+ from typing import Any
15
+
16
+ import click
17
+
18
+ from . import auth
19
+ from .client import APIError, request
20
+
21
+ EXIT_GENERAL = 1
22
+ EXIT_USAGE = 2
23
+ EXIT_AUTH = 3
24
+ EXIT_FORBIDDEN = 4
25
+ EXIT_NOT_FOUND = 5
26
+ EXIT_VALIDATION = 6
27
+ EXIT_NETWORK = 7
28
+
29
+
30
+ def _exit_code(error: APIError) -> int:
31
+ status = error.status_code
32
+ if status == 401:
33
+ return EXIT_AUTH
34
+ if status in (402, 403):
35
+ return EXIT_FORBIDDEN
36
+ if status == 404:
37
+ return EXIT_NOT_FOUND
38
+ if status in (400, 409):
39
+ return EXIT_VALIDATION
40
+ if status == 0:
41
+ message = str(error)
42
+ if "Network error" in message:
43
+ return EXIT_NETWORK
44
+ if "Not authenticated" in message:
45
+ return EXIT_AUTH
46
+ return EXIT_GENERAL
47
+
48
+
49
+ def _fail(error: APIError) -> None:
50
+ """Print the API's error detail to stderr and exit non-zero."""
51
+ click.echo(str(error), err=True)
52
+ sys.exit(_exit_code(error))
53
+
54
+
55
+ def api_request(method: str, path: str, *, token_flag: str = "", **kwargs) -> Any:
56
+ """Authenticated request with a single 401-triggered token refresh.
57
+
58
+ Refresh only applies to keychain-stored OAuth tokens. A rejected PAT is
59
+ never "refreshed" — the stored refresh credentials would belong to a
60
+ different login — so a PAT 401 becomes a clear re-login instruction.
61
+ """
62
+ token, source = auth.resolve_token(token_flag)
63
+ try:
64
+ return request(method, path, token, **kwargs)
65
+ except APIError as error:
66
+ if error.status_code == 401 and source == "keyring":
67
+ kind = auth.stored_token_kind()
68
+ if kind == "pat":
69
+ raise APIError(
70
+ 401,
71
+ "The stored personal access token was rejected (revoked "
72
+ "or expired). Create a new token at /accounts/tokens/ and "
73
+ "run 'withfeedback login --token <PAT>' again.",
74
+ ) from error
75
+ new_token = auth.refresh_access_token()
76
+ if new_token:
77
+ return request(method, path, new_token, **kwargs)
78
+ raise
79
+
80
+
81
+ def _emit(data: Any, json_mode: bool, human) -> None:
82
+ """Print JSON in --json mode, else call the human formatter."""
83
+ if json_mode:
84
+ click.echo(json.dumps(data, indent=2))
85
+ else:
86
+ human(data)
87
+
88
+
89
+ def _rows(data: Any) -> list[dict]:
90
+ """Unwrap DRF pagination envelopes into a plain list."""
91
+ if isinstance(data, dict):
92
+ return data.get("results", [])
93
+ return data or []
94
+
95
+
96
+ def print_table(headers: list[str], rows: list[list[str]]) -> None:
97
+ """Plain fixed-width table (no external dependencies)."""
98
+ if not rows:
99
+ click.echo("(no results)")
100
+ return
101
+ table = [headers] + [[str(cell) for cell in row] for row in rows]
102
+ widths = [max(len(row[i]) for row in table) for i in range(len(headers))]
103
+ for index, row in enumerate(table):
104
+ click.echo(" ".join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip())
105
+ if index == 0:
106
+ click.echo(" ".join("-" * width for width in widths))
107
+
108
+
109
+ def _truncate(text: str, limit: int = 50) -> str:
110
+ text = (text or "").replace("\n", " ")
111
+ return text if len(text) <= limit else text[: limit - 1] + "…"
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Root group + auth commands
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ @click.group()
120
+ @click.version_option(package_name="withfeedback", prog_name="withfeedback")
121
+ def cli() -> None:
122
+ """withfeedback.com — testimonials, feedback, widgets, and NPS.
123
+
124
+ Base URL defaults to https://withfeedback.com; override with the
125
+ WITHFEEDBACK_API_URL environment variable.
126
+ """
127
+
128
+
129
+ @cli.command()
130
+ @click.option("--client-id", envvar="WITHFEEDBACK_CLIENT_ID",
131
+ default=auth.DEFAULT_CLIENT_ID, show_default=True,
132
+ help="OAuth2 device-flow client id (default: the official "
133
+ "withfeedback.com public client).")
134
+ @click.option("--token", default="",
135
+ help="Skip the device flow and store this personal access token instead.")
136
+ @click.option("--scope", default=None, help="Scopes to request (device flow only).")
137
+ def login(client_id: str, token: str, scope: str | None) -> None:
138
+ """Authenticate and store tokens in the OS keychain.
139
+
140
+ Default: OAuth2 device flow (opens a browser, you enter a code).
141
+ With --token, the personal access token is stored instead.
142
+ """
143
+ try:
144
+ if token:
145
+ auth.store_tokens(token, kind="pat")
146
+ click.echo("Personal access token stored in the OS keychain.")
147
+ return
148
+ if not client_id:
149
+ click.echo(
150
+ "Empty --client-id. Pass a device-flow client id or use "
151
+ "--token to store a personal access token.",
152
+ err=True,
153
+ )
154
+ sys.exit(EXIT_USAGE)
155
+ payload = auth.device_flow(client_id, scope)
156
+ auth.store_tokens(
157
+ payload["access_token"],
158
+ payload.get("refresh_token", ""),
159
+ kind="oauth",
160
+ client_id=client_id,
161
+ )
162
+ click.echo("Logged in. Tokens stored in the OS keychain.")
163
+ except APIError as error:
164
+ _fail(error)
165
+
166
+
167
+ @cli.command()
168
+ def logout() -> None:
169
+ """Remove stored tokens from the OS keychain."""
170
+ auth.clear_tokens()
171
+ click.echo("Logged out — stored tokens removed.")
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Testimonials
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ @cli.group()
180
+ def testimonials() -> None:
181
+ """List, moderate, and create testimonials."""
182
+
183
+
184
+ @testimonials.command(name="list")
185
+ @click.option("--team", required=True, type=click.UUID, help="Team UUID.")
186
+ @click.option("--project", required=True, type=click.UUID, help="Project UUID.")
187
+ @click.option("--status", default=None,
188
+ type=click.Choice(["pending", "approved", "rejected", "spam"]),
189
+ help="Filter by moderation status.")
190
+ @click.option("--kind", default="testimonial",
191
+ type=click.Choice(["testimonial", "feedback", "survey_response", "all"]),
192
+ show_default=True, help="Submission kind ('all' disables the filter).")
193
+ @click.option("--rating", type=click.IntRange(1, 5), default=None,
194
+ help="Filter by exact star rating.")
195
+ @click.option("--q", "query", default=None, help="Search within the text.")
196
+ @click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
197
+ @click.option("--token", "token_flag", default="", help="PAT override for this call.")
198
+ def testimonials_list(team, project, status, kind, rating, query, json_mode, token_flag):
199
+ """List a project's testimonials (scope: read:feedback)."""
200
+ params: dict[str, Any] = {}
201
+ if kind != "all":
202
+ params["kind"] = kind
203
+ if status:
204
+ params["status"] = status
205
+ if rating is not None:
206
+ params["rating"] = rating
207
+ if query:
208
+ params["q"] = query
209
+ try:
210
+ data = api_request(
211
+ "GET",
212
+ f"/api/v1/teams/{team}/projects/{project}/submissions/",
213
+ params=params or None,
214
+ token_flag=token_flag,
215
+ )
216
+ except APIError as error:
217
+ _fail(error)
218
+
219
+ def human(data):
220
+ rows = [
221
+ [
222
+ item.get("id", ""),
223
+ item.get("status", ""),
224
+ item.get("rating") if item.get("rating") is not None else "-",
225
+ (item.get("contact") or {}).get("name") or "-",
226
+ (item.get("created_at") or "")[:10],
227
+ _truncate(item.get("text", "")),
228
+ ]
229
+ for item in _rows(data)
230
+ ]
231
+ print_table(["ID", "STATUS", "RATING", "NAME", "CREATED", "TEXT"], rows)
232
+ if isinstance(data, dict) and data.get("next"):
233
+ click.echo("(more results available — use --json to page with the cursor)")
234
+
235
+ _emit(data, json_mode, human)
236
+
237
+
238
+ def _moderation_command(action: str):
239
+ @click.argument("submission_id", type=click.UUID)
240
+ @click.option("--team", required=True, type=click.UUID, help="Team UUID.")
241
+ @click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
242
+ @click.option("--token", "token_flag", default="", help="PAT override for this call.")
243
+ def command(submission_id, team, json_mode, token_flag):
244
+ try:
245
+ data = api_request(
246
+ "POST",
247
+ f"/api/v1/teams/{team}/submissions/{submission_id}/{action}/",
248
+ token_flag=token_flag,
249
+ )
250
+ except APIError as error:
251
+ _fail(error)
252
+ _emit(
253
+ data,
254
+ json_mode,
255
+ lambda d: click.echo(f"{action.capitalize()}d {d.get('id')} (status: {d.get('status')})"),
256
+ )
257
+
258
+ return command
259
+
260
+
261
+ testimonials.command(name="approve", help=(
262
+ "Approve a submission (scope: moderate:submissions)."
263
+ ))(_moderation_command("approve"))
264
+ testimonials.command(name="reject", help=(
265
+ "Reject or unpublish a submission (scope: moderate:submissions)."
266
+ ))(_moderation_command("reject"))
267
+
268
+
269
+ @testimonials.command(name="create")
270
+ @click.option("--team", required=True, type=click.UUID, help="Team UUID.")
271
+ @click.option("--project", required=True, type=click.UUID, help="Project UUID.")
272
+ @click.option("--text", required=True, help="Testimonial text.")
273
+ @click.option("--rating", type=click.IntRange(1, 5), default=None, help="Star rating (1-5).")
274
+ @click.option("--name", default="", help="Author name.")
275
+ @click.option("--email", default="", help="Author email.")
276
+ @click.option("--title", "job_title", default="", help="Author job title.")
277
+ @click.option("--company", default="", help="Author company.")
278
+ @click.option("--consent/--no-consent", "consent_display", default=False,
279
+ help="Whether display consent was collected.")
280
+ @click.option("--tag", "tags", multiple=True, help="Tag (repeatable).")
281
+ @click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
282
+ @click.option("--token", "token_flag", default="", help="PAT override for this call.")
283
+ def testimonials_create(team, project, text, rating, name, email, job_title,
284
+ company, consent_display, tags, json_mode, token_flag):
285
+ """Create a pending testimonial (scope: write:testimonials)."""
286
+ body: dict[str, Any] = {
287
+ "kind": "testimonial",
288
+ "text": text,
289
+ "consent_display": consent_display,
290
+ }
291
+ if rating is not None:
292
+ body["rating"] = rating
293
+ if tags:
294
+ body["tags"] = list(tags)
295
+ if email:
296
+ body["contact_email"] = email
297
+ if name:
298
+ body["contact_name"] = name
299
+ if job_title:
300
+ body["contact_title"] = job_title
301
+ if company:
302
+ body["contact_company"] = company
303
+ try:
304
+ data = api_request(
305
+ "POST",
306
+ f"/api/v1/teams/{team}/projects/{project}/submissions/",
307
+ json_body=body,
308
+ token_flag=token_flag,
309
+ )
310
+ except APIError as error:
311
+ _fail(error)
312
+ _emit(
313
+ data,
314
+ json_mode,
315
+ lambda d: click.echo(f"Created {d.get('id')} (status: {d.get('status')})"),
316
+ )
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # CSV import
321
+ # ---------------------------------------------------------------------------
322
+
323
+
324
+ @cli.group(name="import")
325
+ def import_group() -> None:
326
+ """Bulk imports."""
327
+
328
+
329
+ @import_group.command(name="csv")
330
+ @click.argument("file", type=click.Path(exists=True, dir_okay=False))
331
+ @click.option("--team", required=True, type=click.UUID, help="Team UUID.")
332
+ @click.option("--project", required=True, type=click.UUID, help="Project UUID.")
333
+ @click.option("--text-col", required=True, help="CSV column with the testimonial text.")
334
+ @click.option("--rating-col", default=None, help="CSV column with the 1-5 rating.")
335
+ @click.option("--name-col", default=None, help="CSV column with the author name.")
336
+ @click.option("--email-col", default=None, help="CSV column with the author email.")
337
+ @click.option("--consent/--no-consent", default=False,
338
+ help="Whether display consent was collected for these rows.")
339
+ @click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
340
+ @click.option("--token", "token_flag", default="", help="PAT override for this call.")
341
+ def import_csv(file, team, project, text_col, rating_col, name_col, email_col,
342
+ consent, json_mode, token_flag):
343
+ """Import testimonials from a CSV file (scope: write:feedback).
344
+
345
+ The file is validated locally (headers, row count), then uploaded to the
346
+ server's import endpoint; rows land as pending submissions and are
347
+ processed asynchronously.
348
+ """
349
+ try:
350
+ with open(file, "r", encoding="utf-8-sig", newline="") as handle:
351
+ content = handle.read()
352
+ except OSError as error:
353
+ click.echo(f"Cannot read {file}: {error}", err=True)
354
+ sys.exit(EXIT_USAGE)
355
+
356
+ reader = csv.DictReader(io.StringIO(content))
357
+ headers = reader.fieldnames or []
358
+ mapping = {"text": text_col}
359
+ if rating_col:
360
+ mapping["rating"] = rating_col
361
+ if name_col:
362
+ mapping["name"] = name_col
363
+ if email_col:
364
+ mapping["email"] = email_col
365
+
366
+ missing = [col for col in mapping.values() if col not in headers]
367
+ if missing:
368
+ click.echo(
369
+ f"Column(s) not found in the CSV header: {', '.join(missing)}. "
370
+ f"Available columns: {', '.join(headers) or '(none)'}",
371
+ err=True,
372
+ )
373
+ sys.exit(EXIT_USAGE)
374
+
375
+ row_count = sum(1 for _ in reader)
376
+ if row_count == 0:
377
+ click.echo("The CSV has a header but no data rows.", err=True)
378
+ sys.exit(EXIT_USAGE)
379
+
380
+ try:
381
+ data = api_request(
382
+ "POST",
383
+ f"/api/v1/teams/{team}/projects/{project}/imports/",
384
+ data={
385
+ "mapping": json.dumps(mapping),
386
+ "consent": "true" if consent else "false",
387
+ },
388
+ files={"file": (file.rsplit("/", 1)[-1], content.encode("utf-8"), "text/csv")},
389
+ token_flag=token_flag,
390
+ )
391
+ except APIError as error:
392
+ _fail(error)
393
+
394
+ def human(d):
395
+ click.echo(f"Import accepted: batch {d.get('id')} ({row_count} rows).")
396
+ click.echo(f"Status: {d.get('status')}")
397
+ if d.get("status_url"):
398
+ click.echo(f"Poll: {d['status_url']}")
399
+
400
+ _emit(data, json_mode, human)
401
+
402
+
403
+ # ---------------------------------------------------------------------------
404
+ # Surveys / NPS
405
+ # ---------------------------------------------------------------------------
406
+
407
+
408
+ @cli.group()
409
+ def surveys() -> None:
410
+ """Surveys."""
411
+
412
+
413
+ @surveys.command(name="list")
414
+ @click.option("--team", required=True, type=click.UUID, help="Team UUID.")
415
+ @click.option("--project", required=True, type=click.UUID, help="Project UUID.")
416
+ @click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
417
+ @click.option("--token", "token_flag", default="", help="PAT override for this call.")
418
+ def surveys_list(team, project, json_mode, token_flag):
419
+ """List a project's surveys (scope: read:surveys)."""
420
+ try:
421
+ data = api_request(
422
+ "GET",
423
+ f"/api/v1/teams/{team}/projects/{project}/surveys/",
424
+ token_flag=token_flag,
425
+ )
426
+ except APIError as error:
427
+ _fail(error)
428
+
429
+ def human(data):
430
+ rows = [
431
+ [
432
+ item.get("id", ""),
433
+ item.get("name", ""),
434
+ item.get("survey_type", ""),
435
+ item.get("status", ""),
436
+ item.get("current_version") if item.get("current_version") is not None else "-",
437
+ ]
438
+ for item in _rows(data)
439
+ ]
440
+ print_table(["ID", "NAME", "TYPE", "STATUS", "VERSION"], rows)
441
+
442
+ _emit(data, json_mode, human)
443
+
444
+
445
+ @cli.group()
446
+ def nps() -> None:
447
+ """NPS surveys."""
448
+
449
+
450
+ @nps.command(name="summary")
451
+ @click.option("--team", required=True, type=click.UUID, help="Team UUID.")
452
+ @click.option("--project", required=True, type=click.UUID, help="Project UUID.")
453
+ @click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
454
+ @click.option("--token", "token_flag", default="", help="PAT override for this call.")
455
+ def nps_summary(team, project, json_mode, token_flag):
456
+ """NPS results per NPS survey (scope: read:surveys)."""
457
+ try:
458
+ surveys_data = api_request(
459
+ "GET",
460
+ f"/api/v1/teams/{team}/projects/{project}/surveys/",
461
+ token_flag=token_flag,
462
+ )
463
+ nps_surveys = [
464
+ s for s in _rows(surveys_data) if s.get("survey_type") == "nps"
465
+ ]
466
+ summaries = []
467
+ for survey in nps_surveys:
468
+ results = api_request(
469
+ "GET",
470
+ f"/api/v1/teams/{team}/projects/{project}/surveys/"
471
+ f"{survey['id']}/results/",
472
+ token_flag=token_flag,
473
+ )
474
+ summaries.append(
475
+ {
476
+ "survey_id": survey["id"],
477
+ "name": survey.get("name"),
478
+ "status": survey.get("status"),
479
+ "results": results,
480
+ }
481
+ )
482
+ except APIError as error:
483
+ _fail(error)
484
+
485
+ payload = {"project_id": project, "nps_surveys": summaries}
486
+
487
+ def human(payload):
488
+ if not payload["nps_surveys"]:
489
+ click.echo("This project has no NPS surveys.")
490
+ return
491
+ for entry in payload["nps_surveys"]:
492
+ results = entry["results"] or {}
493
+ current = next(
494
+ (v for v in results.get("versions", []) if v.get("is_current")),
495
+ None,
496
+ ) or (results.get("versions") or [{}])[-1]
497
+ nps_value = current.get("nps")
498
+ click.echo(f"{entry['name']} ({entry['survey_id']})")
499
+ click.echo(f" NPS: {nps_value if nps_value is not None else 'n/a'}")
500
+ click.echo(f" Promoters: {current.get('promoters', 0)}")
501
+ click.echo(f" Passives: {current.get('passives', 0)}")
502
+ click.echo(f" Detractors: {current.get('detractors', 0)}")
503
+ click.echo(f" Responses: {results.get('total', 0)}")
504
+
505
+ _emit(payload, json_mode, human)
506
+
507
+
508
+ # ---------------------------------------------------------------------------
509
+ # Widgets + usage
510
+ # ---------------------------------------------------------------------------
511
+
512
+
513
+ @cli.group()
514
+ def widgets() -> None:
515
+ """Embeddable widgets."""
516
+
517
+
518
+ @widgets.command(name="list")
519
+ @click.option("--team", required=True, type=click.UUID, help="Team UUID.")
520
+ @click.option("--project", required=True, type=click.UUID, help="Project UUID.")
521
+ @click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
522
+ @click.option("--token", "token_flag", default="", help="PAT override for this call.")
523
+ def widgets_list(team, project, json_mode, token_flag):
524
+ """List a project's widgets (scope: read:widgets)."""
525
+ try:
526
+ data = api_request(
527
+ "GET",
528
+ f"/api/v1/teams/{team}/projects/{project}/widgets/",
529
+ token_flag=token_flag,
530
+ )
531
+ except APIError as error:
532
+ _fail(error)
533
+
534
+ def human(data):
535
+ rows = [
536
+ [
537
+ item.get("id", ""),
538
+ item.get("name", ""),
539
+ item.get("widget_type", ""),
540
+ "yes" if item.get("is_active") else "no",
541
+ ]
542
+ for item in _rows(data)
543
+ ]
544
+ print_table(["ID", "NAME", "TYPE", "ACTIVE"], rows)
545
+
546
+ _emit(data, json_mode, human)
547
+
548
+
549
+ @cli.command()
550
+ @click.option("--team", required=True, type=click.UUID, help="Team UUID.")
551
+ @click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
552
+ @click.option("--token", "token_flag", default="", help="PAT override for this call.")
553
+ def usage(team, json_mode, token_flag):
554
+ """Quota usage vs plan limits (scope: read:feedback)."""
555
+ try:
556
+ data = api_request(
557
+ "GET", f"/api/v1/teams/{team}/usage/", token_flag=token_flag
558
+ )
559
+ except APIError as error:
560
+ _fail(error)
561
+
562
+ def human(data):
563
+ click.echo(f"Plan: {data.get('plan')}")
564
+ rows = []
565
+ for metric, entry in (data.get("usage") or {}).items():
566
+ used = (entry.get("settled") or 0) + (entry.get("reserved") or 0)
567
+ limit = entry.get("limit")
568
+ rows.append(
569
+ [
570
+ metric,
571
+ entry.get("period", ""),
572
+ used,
573
+ "unlimited" if limit is None else limit,
574
+ ]
575
+ )
576
+ print_table(["METRIC", "PERIOD", "USED", "LIMIT"], rows)
577
+
578
+ _emit(data, json_mode, human)
579
+
580
+
581
+ if __name__ == "__main__":
582
+ cli()