pushmetrics-cli 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.
- pm/__init__.py +1 -0
- pm/__main__.py +4 -0
- pm/_alias.py +164 -0
- pm/auth/__init__.py +0 -0
- pm/auth/device_flow.py +187 -0
- pm/auth/exchange.py +79 -0
- pm/auth/jwt_cache.py +163 -0
- pm/auth/secret_files.py +58 -0
- pm/auth/session.py +440 -0
- pm/auth/store.py +160 -0
- pm/auth/token.py +289 -0
- pm/cli.py +282 -0
- pm/client/__init__.py +68 -0
- pm/client/access_tokens.py +54 -0
- pm/client/base.py +229 -0
- pm/client/blocks.py +461 -0
- pm/client/databases.py +147 -0
- pm/client/favorites.py +14 -0
- pm/client/folders.py +10 -0
- pm/client/integrations.py +14 -0
- pm/client/organizations.py +20 -0
- pm/client/recipients.py +92 -0
- pm/client/reports.py +148 -0
- pm/client/roles.py +100 -0
- pm/client/runs.py +134 -0
- pm/client/saved_blocks.py +13 -0
- pm/client/tableau.py +108 -0
- pm/client/users.py +71 -0
- pm/client/webhooks.py +43 -0
- pm/client/workspaces.py +34 -0
- pm/commands/__init__.py +0 -0
- pm/commands/_block_factory.py +762 -0
- pm/commands/_helpers.py +178 -0
- pm/commands/auth.py +222 -0
- pm/commands/block.py +26 -0
- pm/commands/chart.py +20 -0
- pm/commands/db.py +291 -0
- pm/commands/email.py +13 -0
- pm/commands/explore.py +20 -0
- pm/commands/favorites.py +77 -0
- pm/commands/folders.py +113 -0
- pm/commands/help.py +643 -0
- pm/commands/integrations.py +95 -0
- pm/commands/metrics_view.py +195 -0
- pm/commands/organizations.py +56 -0
- pm/commands/recipient.py +226 -0
- pm/commands/reports.py +364 -0
- pm/commands/roles.py +102 -0
- pm/commands/runs.py +214 -0
- pm/commands/saved_blocks.py +97 -0
- pm/commands/sftp.py +19 -0
- pm/commands/slack.py +13 -0
- pm/commands/slides.py +216 -0
- pm/commands/sql.py +17 -0
- pm/commands/tableau.py +148 -0
- pm/commands/token.py +107 -0
- pm/commands/users.py +262 -0
- pm/commands/webhooks.py +132 -0
- pm/commands/workspace.py +156 -0
- pm/config.py +192 -0
- pm/errors.py +228 -0
- pm/log.py +63 -0
- pm/models/__init__.py +31 -0
- pm/models/access_tokens.py +25 -0
- pm/models/blocks.py +21 -0
- pm/models/databases.py +15 -0
- pm/models/favorites.py +14 -0
- pm/models/folders.py +14 -0
- pm/models/integrations.py +20 -0
- pm/models/organizations.py +13 -0
- pm/models/recipients.py +41 -0
- pm/models/reports.py +16 -0
- pm/models/roles.py +22 -0
- pm/models/runs.py +56 -0
- pm/models/saved_blocks.py +15 -0
- pm/models/users.py +36 -0
- pm/models/webhooks.py +23 -0
- pm/models/workspaces.py +16 -0
- pm/output.py +92 -0
- pm/types.py +130 -0
- pm/validators.py +499 -0
- pushmetrics_cli-0.1.0.dist-info/METADATA +221 -0
- pushmetrics_cli-0.1.0.dist-info/RECORD +86 -0
- pushmetrics_cli-0.1.0.dist-info/WHEEL +5 -0
- pushmetrics_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pushmetrics_cli-0.1.0.dist-info/top_level.txt +1 -0
pm/auth/token.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Bearer Personal Access Token (PAT) auth for the pm CLI.
|
|
2
|
+
|
|
3
|
+
A PAT (``pmpat_...``) is exchanged at ``POST /api/v1/security/exchange`` for a
|
|
4
|
+
short-lived, workspace-scoped FAB JWT. The JWT is then sent as
|
|
5
|
+
``Authorization: Bearer <jwt>`` on subsequent requests; the backend's
|
|
6
|
+
``load_user_jwt`` reads the token's ``workspace_id`` claim and installs it as the
|
|
7
|
+
request's effective workspace (roles + RLS), so the token is workspace-bound end
|
|
8
|
+
to end.
|
|
9
|
+
|
|
10
|
+
The raw PAT is resolved from (in priority order):
|
|
11
|
+
|
|
12
|
+
1. the ``PM_API_TOKEN`` environment variable (agent / CI / one-off use), or
|
|
13
|
+
2. a per-profile token stored via ``pm login --token`` (keyring, or a
|
|
14
|
+
``~/.pm/tokens.json`` fallback file chmod 0600).
|
|
15
|
+
|
|
16
|
+
This path carries no cookie jar and does not touch the Auth0 device flow. It is
|
|
17
|
+
deliberately kept distinct from ``StoredCredentials.pm_access_token`` (the FAB
|
|
18
|
+
JWT minted by the Auth0 login path) so the two auth modes never collide.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
import click
|
|
28
|
+
import requests
|
|
29
|
+
|
|
30
|
+
from pm.auth import jwt_cache
|
|
31
|
+
from pm.auth.secret_files import read_private_json, write_private_json
|
|
32
|
+
from pm.config import CONFIG_DIR, keyring_account
|
|
33
|
+
from pm.log import log_request, log_response, redact_secrets, timed
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import keyring
|
|
37
|
+
_KEYRING_AVAILABLE = True
|
|
38
|
+
except Exception:
|
|
39
|
+
_KEYRING_AVAILABLE = False
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
TOKEN_ENV_VAR = "PM_API_TOKEN"
|
|
44
|
+
# Distinct from store.KEYRING_SERVICE ("pushmetrics-cli") so a stored PAT never
|
|
45
|
+
# overwrites or shadows the Auth0 cookie/JWT credentials for the same profile.
|
|
46
|
+
KEYRING_SERVICE = "pushmetrics-cli-pat"
|
|
47
|
+
FALLBACK_FILE = CONFIG_DIR / "tokens.json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TokenExchangeError(click.ClickException):
|
|
51
|
+
"""A PAT could not be exchanged for a JWT (disabled, invalid, or revoked).
|
|
52
|
+
|
|
53
|
+
A ``ClickException`` so it renders as a clean one-line error rather than a
|
|
54
|
+
traceback when it bubbles out of a command. ``status`` carries the HTTP
|
|
55
|
+
status the exchange endpoint answered with, so callers can word a 429
|
|
56
|
+
(rate limited) differently from a 401 without parsing the message; it is
|
|
57
|
+
``None`` when the failure was not an HTTP status (malformed 200 body).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, message: str, status: "int | None" = None):
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.status = status
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ExchangeResult:
|
|
67
|
+
access_token: str
|
|
68
|
+
expires_in: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# The backend's own token shape (superset/pushmetrics/security/access_token.py
|
|
72
|
+
# _TOKEN_RE), copied because the CLI must not import the backend (G3). A
|
|
73
|
+
# token with whitespace or control characters must be refused BEFORE it
|
|
74
|
+
# reaches requests: an inner newline in a header value raises InvalidHeader,
|
|
75
|
+
# whose message (and the traceback click prints for it) carries the full
|
|
76
|
+
# secret (security report S1).
|
|
77
|
+
_PAT_SHAPE_RE = re.compile(r"^pmpat_[A-Za-z0-9_\-]{40,64}$")
|
|
78
|
+
|
|
79
|
+
# One raise site, one string: `pm login --token` reaches this check through
|
|
80
|
+
# exchange_pat_for_jwt and carries no copy of its own. Never echoes the value.
|
|
81
|
+
PAT_SHAPE_HINT = (
|
|
82
|
+
"That does not look like a personal access token (expected pmpat_ "
|
|
83
|
+
"followed by 40-64 letters, digits, - or _, on one line). Paste the "
|
|
84
|
+
"value exactly as shown when it was created."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# The server's own sentence for a valid PAT whose workspace's organization
|
|
89
|
+
# has no active subscription (dev-api: CREDENTIAL_SUBSCRIPTION_REQUIRED_MESSAGE
|
|
90
|
+
# in superset/pushmetrics/views/subscription/decorators.py). Used only when
|
|
91
|
+
# the 402 body does not carry a usable message itself.
|
|
92
|
+
SUBSCRIPTION_REQUIRED_MESSAGE = (
|
|
93
|
+
"Personal access tokens need an active PushMetrics subscription for this "
|
|
94
|
+
"workspace's organization. Start a trial or upgrade in the web app."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _subscription_message(resp: requests.Response) -> str:
|
|
99
|
+
"""The server owns the wording: its JSON `message` when present and a
|
|
100
|
+
non-empty string, else the fixed sentence. Never the raw body (a proxy
|
|
101
|
+
page or JSON blob), and always passed through redaction."""
|
|
102
|
+
try:
|
|
103
|
+
body = resp.json()
|
|
104
|
+
except ValueError:
|
|
105
|
+
return SUBSCRIPTION_REQUIRED_MESSAGE
|
|
106
|
+
message = body.get("message") if isinstance(body, dict) else None
|
|
107
|
+
if isinstance(message, str) and message.strip():
|
|
108
|
+
# Redact first, then cut: a cut inside a token leaves a fragment
|
|
109
|
+
# the redaction pattern no longer recognises.
|
|
110
|
+
return redact_secrets(message.strip())[:300]
|
|
111
|
+
return SUBSCRIPTION_REQUIRED_MESSAGE
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def unreachable_message(api_url: str, exc: BaseException) -> str:
|
|
115
|
+
"""One template for every transport failure (exchange or request): what
|
|
116
|
+
failed, then the next command. No 3-digit status, so the agent harness's
|
|
117
|
+
`failed: NNN` scrape never mistakes it for an API denial."""
|
|
118
|
+
return (
|
|
119
|
+
f"Could not reach {api_url} ({type(exc).__name__}). Check your network "
|
|
120
|
+
f"and the API URL (--api-url, PM_API_URL, or `pm config list`)."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def is_well_formed_pat(raw_token: str) -> bool:
|
|
125
|
+
# fullmatch, not match: `$` would accept a trailing newline, which is a
|
|
126
|
+
# header-breaking character like any other.
|
|
127
|
+
return bool(_PAT_SHAPE_RE.fullmatch(raw_token or ""))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def exchange_pat_for_jwt(
|
|
131
|
+
session: requests.Session,
|
|
132
|
+
api_url: str,
|
|
133
|
+
raw_token: str,
|
|
134
|
+
) -> ExchangeResult:
|
|
135
|
+
"""Exchange a raw PAT for a short-lived workspace-scoped JWT.
|
|
136
|
+
|
|
137
|
+
The PAT travels in the ``Authorization`` header only (never the URL/body).
|
|
138
|
+
Raises ``TokenExchangeError`` on any non-200 or malformed response.
|
|
139
|
+
"""
|
|
140
|
+
if not is_well_formed_pat(raw_token):
|
|
141
|
+
raise TokenExchangeError(PAT_SHAPE_HINT)
|
|
142
|
+
url = f"{api_url}/api/v1/security/exchange"
|
|
143
|
+
started = log_request(logger, "POST", url)
|
|
144
|
+
try:
|
|
145
|
+
resp = session.post(
|
|
146
|
+
url,
|
|
147
|
+
headers={"Authorization": f"Bearer {raw_token}"},
|
|
148
|
+
timeout=30,
|
|
149
|
+
# Never follow a redirect: ``requests`` would re-attach the
|
|
150
|
+
# Authorization header (and thus the raw PAT) on a same-host 302.
|
|
151
|
+
allow_redirects=False,
|
|
152
|
+
)
|
|
153
|
+
except requests.RequestException as exc:
|
|
154
|
+
# A transport error's text can embed the request (and its headers);
|
|
155
|
+
# never let it reach a traceback unredacted.
|
|
156
|
+
raise TokenExchangeError(unreachable_message(api_url, exc))
|
|
157
|
+
log_response(logger, resp, started)
|
|
158
|
+
|
|
159
|
+
if resp.status_code == 403:
|
|
160
|
+
raise TokenExchangeError(
|
|
161
|
+
"Personal access tokens are not enabled on this server.",
|
|
162
|
+
status=resp.status_code,
|
|
163
|
+
)
|
|
164
|
+
if resp.status_code == 401:
|
|
165
|
+
raise TokenExchangeError(
|
|
166
|
+
"Personal access token rejected (invalid, expired, or revoked).",
|
|
167
|
+
status=resp.status_code,
|
|
168
|
+
)
|
|
169
|
+
if resp.status_code == 402:
|
|
170
|
+
# A valid PAT for an organization without an active subscription.
|
|
171
|
+
# "failed: 402" stays in the line: the agent harness scrapes
|
|
172
|
+
# "failed: NNN" to classify the denial.
|
|
173
|
+
raise TokenExchangeError(
|
|
174
|
+
f"Token exchange failed: 402 {_subscription_message(resp)}",
|
|
175
|
+
status=resp.status_code,
|
|
176
|
+
)
|
|
177
|
+
if resp.status_code != 200:
|
|
178
|
+
# Redact in case the body echoes the Authorization header (some proxy
|
|
179
|
+
# / debug error pages do), so the raw PAT can't leak into logs.
|
|
180
|
+
raise TokenExchangeError(
|
|
181
|
+
f"Token exchange failed: {resp.status_code} "
|
|
182
|
+
f"{redact_secrets(resp.text)[:300]}",
|
|
183
|
+
status=resp.status_code,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
body = resp.json() if resp.content else {}
|
|
188
|
+
except ValueError:
|
|
189
|
+
raise TokenExchangeError(
|
|
190
|
+
"Token exchange returned a non-JSON response (got HTML/redirect)."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
access_token = body.get("access_token")
|
|
194
|
+
if not access_token:
|
|
195
|
+
raise TokenExchangeError(
|
|
196
|
+
"Token exchange succeeded (200) but returned no access_token."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
expires_in = int(body.get("expires_in", 900))
|
|
201
|
+
except (TypeError, ValueError):
|
|
202
|
+
expires_in = 900
|
|
203
|
+
|
|
204
|
+
return ExchangeResult(access_token=access_token, expires_in=expires_in)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── storage ─────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _fallback_load_all() -> dict:
|
|
211
|
+
return read_private_json(FALLBACK_FILE)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _fallback_save_all(data: dict) -> None:
|
|
215
|
+
# 0600 from creation, atomically replaced: see pm.auth.secret_files.
|
|
216
|
+
write_private_json(FALLBACK_FILE, data)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _load_stored(profile: str) -> str | None:
|
|
220
|
+
if _KEYRING_AVAILABLE:
|
|
221
|
+
try:
|
|
222
|
+
with timed(logger, "keyring.load_token"):
|
|
223
|
+
raw = keyring.get_password(KEYRING_SERVICE, keyring_account(profile))
|
|
224
|
+
if raw:
|
|
225
|
+
return raw
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
# Keyring backends fail in exotic ways (locked keychain, headless
|
|
228
|
+
# session, missing dbus). Fall through to the fallback file.
|
|
229
|
+
logger.debug("keyring token load failed for %s: %s", profile, exc)
|
|
230
|
+
return _fallback_load_all().get(profile)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def load_token(profile: str) -> str | None:
|
|
234
|
+
"""Resolve the raw PAT for ``profile``: env var first, then stored.
|
|
235
|
+
|
|
236
|
+
Returns ``None`` when neither is set (caller falls back to cookie auth).
|
|
237
|
+
"""
|
|
238
|
+
env = os.environ.get(TOKEN_ENV_VAR)
|
|
239
|
+
if env and env.strip():
|
|
240
|
+
return env.strip()
|
|
241
|
+
return _load_stored(profile)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def save_token(profile: str, raw_token: str) -> None:
|
|
245
|
+
"""Persist a raw PAT for ``profile`` (keyring, else fallback file).
|
|
246
|
+
|
|
247
|
+
A new PAT invalidates any JWT cached from the previous one, so a
|
|
248
|
+
`pm login --token` never keeps presenting the old token's JWT.
|
|
249
|
+
"""
|
|
250
|
+
jwt_cache.clear(profile)
|
|
251
|
+
if _KEYRING_AVAILABLE:
|
|
252
|
+
try:
|
|
253
|
+
with timed(logger, "keyring.save_token"):
|
|
254
|
+
keyring.set_password(KEYRING_SERVICE, keyring_account(profile), raw_token)
|
|
255
|
+
return
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
# Fall through and persist to the 0600 fallback file instead.
|
|
258
|
+
logger.debug("keyring token save failed for %s: %s", profile, exc)
|
|
259
|
+
data = _fallback_load_all()
|
|
260
|
+
data[profile] = raw_token
|
|
261
|
+
_fallback_save_all(data)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def clear_token(profile: str) -> None:
|
|
265
|
+
"""Remove any stored PAT for ``profile`` and the JWT cached from it
|
|
266
|
+
(no-op if none stored)."""
|
|
267
|
+
jwt_cache.clear(profile)
|
|
268
|
+
if _KEYRING_AVAILABLE:
|
|
269
|
+
try:
|
|
270
|
+
keyring.delete_password(KEYRING_SERVICE, keyring_account(profile))
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
# PasswordDeleteError when nothing is stored is the normal case
|
|
273
|
+
# (clear must stay idempotent); anything else is logged and we
|
|
274
|
+
# still clear the fallback file below.
|
|
275
|
+
logger.debug("keyring token delete for %s: %s", profile, exc)
|
|
276
|
+
data = _fallback_load_all()
|
|
277
|
+
if profile in data:
|
|
278
|
+
del data[profile]
|
|
279
|
+
_fallback_save_all(data)
|
|
280
|
+
if _load_stored(profile) is not None:
|
|
281
|
+
# A locked keychain can refuse the delete and still answer reads;
|
|
282
|
+
# `pm logout` must not say "logged out" over a token that still
|
|
283
|
+
# authenticates (security S7).
|
|
284
|
+
raise click.ClickException(
|
|
285
|
+
f"Could not remove the stored personal access token for profile "
|
|
286
|
+
f"{profile!r}: the OS keyring refused the delete. Unlock the keyring "
|
|
287
|
+
f"(service {KEYRING_SERVICE!r}) or remove the entry there, then run "
|
|
288
|
+
f"`pm logout` again."
|
|
289
|
+
)
|
pm/cli.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""pm — PushMetrics CLI root."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from pm._alias import AliasedGroup
|
|
13
|
+
from pm import __version__
|
|
14
|
+
from pm.client.base import PMApiError
|
|
15
|
+
from pm.commands._helpers import info, json_option
|
|
16
|
+
from pm.config import (
|
|
17
|
+
DEFAULT_PROFILES,
|
|
18
|
+
PROFILE_KEYS,
|
|
19
|
+
get_profile,
|
|
20
|
+
load_all,
|
|
21
|
+
set_profile_field,
|
|
22
|
+
)
|
|
23
|
+
from pm.errors import describe_api_error
|
|
24
|
+
from pm.output import emit
|
|
25
|
+
|
|
26
|
+
_cli_logger = logging.getLogger("pm.cli")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Shown first in ``pm --help``, in this order: the commands a new user
|
|
30
|
+
# needs before anything else works.
|
|
31
|
+
_ACCOUNT_COMMANDS = ("login", "logout", "whoami", "token", "config")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RootGroup(AliasedGroup):
|
|
36
|
+
"""Root group: the single place API failures become one-line errors
|
|
37
|
+
with a next-action hint, so no command needs its own ``except
|
|
38
|
+
PMApiError`` and no command can leak a traceback for them. (Transport
|
|
39
|
+
failures are already ``PMConnectionError`` from the session layer.)
|
|
40
|
+
Also renders ``--help`` with the account commands first."""
|
|
41
|
+
|
|
42
|
+
def format_commands(self, ctx: click.Context, formatter) -> None:
|
|
43
|
+
rows = []
|
|
44
|
+
for name in self.list_commands(ctx):
|
|
45
|
+
cmd = self.get_command(ctx, name)
|
|
46
|
+
if cmd is None or cmd.hidden:
|
|
47
|
+
continue
|
|
48
|
+
rows.append((name, cmd.get_short_help_str(limit=formatter.width - 20)))
|
|
49
|
+
account = [r for n in _ACCOUNT_COMMANDS for r in rows if r[0] == n]
|
|
50
|
+
rest = [r for r in rows if r[0] not in _ACCOUNT_COMMANDS]
|
|
51
|
+
with formatter.section("Account"):
|
|
52
|
+
formatter.write_dl(account)
|
|
53
|
+
with formatter.section("Commands"):
|
|
54
|
+
formatter.write_dl(rest)
|
|
55
|
+
|
|
56
|
+
def invoke(self, ctx: click.Context):
|
|
57
|
+
try:
|
|
58
|
+
return super().invoke(ctx)
|
|
59
|
+
except PMApiError as exc:
|
|
60
|
+
raise click.ClickException(
|
|
61
|
+
describe_api_error(exc, group=ctx.invoked_subcommand)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_GETTING_STARTED = """\b
|
|
66
|
+
Get started with a personal access token:
|
|
67
|
+
1. Create a token in the PushMetrics web app under Access Tokens.
|
|
68
|
+
2. pm login --token paste the token at the prompt (stored securely)
|
|
69
|
+
3. pm whoami then: pm report list, pm run list, pm report run <uid>
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@click.group(cls=RootGroup, epilog=_GETTING_STARTED)
|
|
74
|
+
@click.version_option(__version__, prog_name="pm")
|
|
75
|
+
@click.option(
|
|
76
|
+
"--profile",
|
|
77
|
+
default="default",
|
|
78
|
+
show_default=True,
|
|
79
|
+
envvar="PM_PROFILE",
|
|
80
|
+
help="Configuration profile to use.",
|
|
81
|
+
)
|
|
82
|
+
@click.option(
|
|
83
|
+
"--api-url",
|
|
84
|
+
default=None,
|
|
85
|
+
envvar="PM_API_URL",
|
|
86
|
+
help="Override the API URL for this invocation.",
|
|
87
|
+
)
|
|
88
|
+
@click.option(
|
|
89
|
+
"--json",
|
|
90
|
+
"json_output",
|
|
91
|
+
is_flag=True,
|
|
92
|
+
help="Output JSON.",
|
|
93
|
+
)
|
|
94
|
+
@click.option("--verbose", "-v", is_flag=True, help="Verbose logging to stderr.")
|
|
95
|
+
@click.option(
|
|
96
|
+
"--quiet",
|
|
97
|
+
is_flag=True,
|
|
98
|
+
help="Suppress informational stderr messages. Errors still emit.",
|
|
99
|
+
)
|
|
100
|
+
@click.pass_context
|
|
101
|
+
def cli(
|
|
102
|
+
ctx: click.Context,
|
|
103
|
+
profile: str,
|
|
104
|
+
api_url: str | None,
|
|
105
|
+
json_output: bool,
|
|
106
|
+
verbose: bool,
|
|
107
|
+
quiet: bool,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""PushMetrics CLI."""
|
|
110
|
+
if verbose:
|
|
111
|
+
logging.basicConfig(
|
|
112
|
+
level=logging.DEBUG,
|
|
113
|
+
stream=sys.stderr,
|
|
114
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
resolved = get_profile(profile)
|
|
118
|
+
if api_url:
|
|
119
|
+
resolved = replace(resolved, api_url=api_url.rstrip("/"))
|
|
120
|
+
|
|
121
|
+
ctx.ensure_object(dict)
|
|
122
|
+
ctx.obj["profile"] = resolved
|
|
123
|
+
ctx.obj["json"] = json_output
|
|
124
|
+
ctx.obj["verbose"] = verbose
|
|
125
|
+
ctx.obj["quiet"] = quiet
|
|
126
|
+
ctx.obj["_t0"] = time.perf_counter()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@cli.result_callback()
|
|
130
|
+
@click.pass_context
|
|
131
|
+
def _emit_total_time(ctx: click.Context, result, **kwargs) -> None:
|
|
132
|
+
if ctx.obj and ctx.obj.get("verbose"):
|
|
133
|
+
elapsed = (time.perf_counter() - ctx.obj["_t0"]) * 1000
|
|
134
|
+
_cli_logger.debug("[total] %.0fms", elapsed)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@cli.group(name="config", cls=AliasedGroup, aliases={"ls": "list"})
|
|
138
|
+
def config_group() -> None:
|
|
139
|
+
"""Profiles: which PushMetrics server pm talks to."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@config_group.command(name="set")
|
|
143
|
+
@click.argument("profile_name")
|
|
144
|
+
@click.argument("key", type=click.Choice(sorted(PROFILE_KEYS)))
|
|
145
|
+
@click.argument("value")
|
|
146
|
+
@click.pass_context
|
|
147
|
+
def config_set(ctx: click.Context, profile_name: str, key: str, value: str) -> None:
|
|
148
|
+
"""Set one field of a profile.
|
|
149
|
+
|
|
150
|
+
\b
|
|
151
|
+
Example:
|
|
152
|
+
pm config set staging api_url https://staging.example.com
|
|
153
|
+
"""
|
|
154
|
+
set_profile_field(profile_name, key, value)
|
|
155
|
+
info(ctx, f"Updated {profile_name}.{key}.")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@config_group.command(name="list")
|
|
159
|
+
@json_option
|
|
160
|
+
@click.pass_context
|
|
161
|
+
def config_list(ctx: click.Context) -> None:
|
|
162
|
+
"""List profiles and active environment overrides."""
|
|
163
|
+
file_data = load_all()
|
|
164
|
+
current = ctx.obj["profile"].name
|
|
165
|
+
profile_names = sorted(set(DEFAULT_PROFILES) | set(file_data))
|
|
166
|
+
|
|
167
|
+
rows = []
|
|
168
|
+
for name in profile_names:
|
|
169
|
+
merged = {**DEFAULT_PROFILES.get(name, {}), **file_data.get(name, {})}
|
|
170
|
+
rows.append({
|
|
171
|
+
"active": name == current,
|
|
172
|
+
"profile": name,
|
|
173
|
+
"api_url": merged.get("api_url") or "",
|
|
174
|
+
"auth0_domain": merged.get("auth0_domain") or "",
|
|
175
|
+
"auth0_client_id": merged.get("auth0_client_id") or "(not set)",
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
env_overrides = {
|
|
179
|
+
var: value
|
|
180
|
+
for var, value in (
|
|
181
|
+
("PM_PROFILE", os.environ.get("PM_PROFILE")),
|
|
182
|
+
("PM_API_URL", os.environ.get("PM_API_URL")),
|
|
183
|
+
("PM_AUTH0_DOMAIN", os.environ.get("PM_AUTH0_DOMAIN")),
|
|
184
|
+
("PM_AUTH0_CLIENT_ID", os.environ.get("PM_AUTH0_CLIENT_ID")),
|
|
185
|
+
)
|
|
186
|
+
if value
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if ctx.obj["json"]:
|
|
190
|
+
emit(
|
|
191
|
+
{"profiles": rows, "env_overrides": env_overrides},
|
|
192
|
+
json_output=True,
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
emit(
|
|
197
|
+
rows,
|
|
198
|
+
json_output=False,
|
|
199
|
+
columns=[
|
|
200
|
+
("ACTIVE", lambda r: "*" if r["active"] else ""),
|
|
201
|
+
("PROFILE", "profile"),
|
|
202
|
+
("API_URL", "api_url"),
|
|
203
|
+
("AUTH0_DOMAIN", "auth0_domain"),
|
|
204
|
+
("AUTH0_CLIENT_ID", "auth0_client_id"),
|
|
205
|
+
],
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if env_overrides:
|
|
209
|
+
click.echo("")
|
|
210
|
+
click.echo("Active env-var overrides (apply to all profiles):")
|
|
211
|
+
for var, value in env_overrides.items():
|
|
212
|
+
click.echo(f" {var}={value}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
from pm.commands.auth import login, logout, whoami
|
|
216
|
+
from pm.commands.block import block as block_group
|
|
217
|
+
from pm.commands.chart import chart as chart_group
|
|
218
|
+
from pm.commands.help import help_cmd
|
|
219
|
+
from pm.commands.db import db as db_group
|
|
220
|
+
from pm.commands.email import email as email_group
|
|
221
|
+
from pm.commands.explore import explore as explore_group
|
|
222
|
+
from pm.commands.favorites import favorites as favorites_group
|
|
223
|
+
from pm.commands.folders import folders as folders_group
|
|
224
|
+
from pm.commands.integrations import integrations as integrations_group
|
|
225
|
+
from pm.commands.metrics_view import metrics_view as metrics_view_group
|
|
226
|
+
from pm.commands.organizations import organizations as organizations_group
|
|
227
|
+
from pm.commands.recipient import recipient as recipient_group
|
|
228
|
+
from pm.commands.reports import reports as reports_group
|
|
229
|
+
from pm.commands.roles import roles as roles_group
|
|
230
|
+
from pm.commands.runs import runs as runs_group
|
|
231
|
+
from pm.commands.saved_blocks import saved_blocks as saved_blocks_group
|
|
232
|
+
from pm.commands.sftp import sftp as sftp_group
|
|
233
|
+
from pm.commands.slack import slack as slack_group
|
|
234
|
+
from pm.commands.slides import slides as slides_group
|
|
235
|
+
from pm.commands.sql import sql as sql_group
|
|
236
|
+
from pm.commands.tableau import tableau as tableau_group
|
|
237
|
+
from pm.commands.token import token as token_group
|
|
238
|
+
from pm.commands.users import users as users_group
|
|
239
|
+
from pm.commands.webhooks import webhooks as webhooks_group
|
|
240
|
+
from pm.commands.workspace import workspace as workspace_group
|
|
241
|
+
|
|
242
|
+
cli.add_command(login)
|
|
243
|
+
cli.add_command(logout)
|
|
244
|
+
cli.add_command(whoami)
|
|
245
|
+
cli.add_command(help_cmd)
|
|
246
|
+
cli.add_command(token_group)
|
|
247
|
+
cli.add_command(workspace_group)
|
|
248
|
+
# pm-consolidation follow-up — every resource group registered ONLY under
|
|
249
|
+
# its singular canonical name. The plural aliases (``reports``,
|
|
250
|
+
# ``folders``, ``favorites``, ``runs``, ``users``, ``integrations``,
|
|
251
|
+
# ``organizations``, ``roles``, ``saved-blocks``, ``webhooks``) and the
|
|
252
|
+
# ``db`` shortform were dropped to keep one surface — singular matches
|
|
253
|
+
# the API resource names (e.g. /api/v1/report/, /api/v1/folder/) and the
|
|
254
|
+
# agent's canonical-action keys. Agent legacy tools migrated in commit
|
|
255
|
+
# 0d898f2d80.
|
|
256
|
+
cli.add_command(reports_group, name="report")
|
|
257
|
+
cli.add_command(runs_group, name="run")
|
|
258
|
+
cli.add_command(db_group, name="database")
|
|
259
|
+
cli.add_command(users_group, name="user")
|
|
260
|
+
cli.add_command(folders_group, name="folder")
|
|
261
|
+
cli.add_command(integrations_group, name="integration")
|
|
262
|
+
cli.add_command(webhooks_group, name="webhook")
|
|
263
|
+
cli.add_command(favorites_group, name="favorite")
|
|
264
|
+
cli.add_command(organizations_group, name="organization")
|
|
265
|
+
cli.add_command(recipient_group)
|
|
266
|
+
cli.add_command(roles_group, name="role")
|
|
267
|
+
cli.add_command(saved_blocks_group, name="saved-block")
|
|
268
|
+
|
|
269
|
+
# pm-foundation — factory-built block-typed groups. Singular by default;
|
|
270
|
+
# every block-typed group exposes some subset of create / run / send and
|
|
271
|
+
# delegates the HTTP work to BlocksClient. CRUD discovery across types is
|
|
272
|
+
# `pm block` (plumbing).
|
|
273
|
+
cli.add_command(sql_group)
|
|
274
|
+
cli.add_command(email_group)
|
|
275
|
+
cli.add_command(slack_group)
|
|
276
|
+
cli.add_command(sftp_group)
|
|
277
|
+
cli.add_command(chart_group)
|
|
278
|
+
cli.add_command(explore_group)
|
|
279
|
+
cli.add_command(metrics_view_group)
|
|
280
|
+
cli.add_command(tableau_group)
|
|
281
|
+
cli.add_command(slides_group)
|
|
282
|
+
cli.add_command(block_group)
|
pm/client/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""PMClient — facade exposing per-resource clients off a single session.
|
|
2
|
+
|
|
3
|
+
Intended uses:
|
|
4
|
+
- CLI commands: `client = PMClient.from_profile(profile); client.reports.list()`
|
|
5
|
+
- Step-2 Claude tools: each `client.<resource>.<method>` is a callable tool
|
|
6
|
+
|
|
7
|
+
The per-resource classes (`UsersClient`, `ReportsClient`, ...) remain importable
|
|
8
|
+
directly for tests and for code that needs a single resource without paying for
|
|
9
|
+
the rest. The facade just composes them onto one `PMSession`.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pm.auth.session import PMSession
|
|
14
|
+
from pm.client.access_tokens import AccessTokensClient
|
|
15
|
+
from pm.client.blocks import BlocksClient
|
|
16
|
+
from pm.client.databases import DatabasesClient
|
|
17
|
+
from pm.client.favorites import FavoritesClient
|
|
18
|
+
from pm.client.folders import FoldersClient
|
|
19
|
+
from pm.client.integrations import IntegrationsClient
|
|
20
|
+
from pm.client.organizations import OrganizationsClient
|
|
21
|
+
from pm.client.recipients import RecipientsClient
|
|
22
|
+
from pm.client.reports import ReportsClient
|
|
23
|
+
from pm.client.roles import RolesClient
|
|
24
|
+
from pm.client.runs import RunsClient
|
|
25
|
+
from pm.client.saved_blocks import SavedBlocksClient
|
|
26
|
+
from pm.client.tableau import TableauClient
|
|
27
|
+
from pm.client.users import UsersClient
|
|
28
|
+
from pm.client.webhooks import WebhooksClient
|
|
29
|
+
from pm.client.workspaces import WorkspacesClient
|
|
30
|
+
from pm.config import Profile
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PMClient:
|
|
34
|
+
"""Top-level client. Holds a single `PMSession` and per-resource clients.
|
|
35
|
+
|
|
36
|
+
Resource clients share the underlying session, so workspace context flows
|
|
37
|
+
through every call.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, session: PMSession):
|
|
41
|
+
self.session = session
|
|
42
|
+
self.users = UsersClient(session)
|
|
43
|
+
self.workspaces = WorkspacesClient(session)
|
|
44
|
+
self.reports = ReportsClient(session)
|
|
45
|
+
self.runs = RunsClient(session)
|
|
46
|
+
self.databases = DatabasesClient(session)
|
|
47
|
+
self.folders = FoldersClient(session)
|
|
48
|
+
self.integrations = IntegrationsClient(session)
|
|
49
|
+
self.webhooks = WebhooksClient(session)
|
|
50
|
+
self.favorites = FavoritesClient(session)
|
|
51
|
+
self.organizations = OrganizationsClient(session)
|
|
52
|
+
self.recipients = RecipientsClient(session)
|
|
53
|
+
self.roles = RolesClient(session)
|
|
54
|
+
self.saved_blocks = SavedBlocksClient(session)
|
|
55
|
+
self.blocks = BlocksClient(session)
|
|
56
|
+
self.tableau = TableauClient(session)
|
|
57
|
+
self.access_tokens = AccessTokensClient(session)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_profile(cls, profile: Profile) -> "PMClient":
|
|
61
|
+
return cls(PMSession.from_profile(profile))
|
|
62
|
+
|
|
63
|
+
def persist(self) -> None:
|
|
64
|
+
"""Write the current cookiejar back to the credential store."""
|
|
65
|
+
self.session.persist()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["PMClient"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Personal Access Token CRUD endpoints (`/api/v1/access_token/`).
|
|
2
|
+
|
|
3
|
+
Self-service management of the caller's own ``user``-kind tokens in the current
|
|
4
|
+
workspace.
|
|
5
|
+
|
|
6
|
+
``create`` is special-cased: the backend returns the raw token exactly once in a
|
|
7
|
+
top-level ``token`` field alongside the serialized metadata in ``result``, so it
|
|
8
|
+
returns a ``(raw_token, AccessToken)`` pair rather than just the model.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from pm.client.base import CrudClient, PMApiError, _unwrap_result, raise_for_status
|
|
15
|
+
from pm.models.access_tokens import AccessToken
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AccessTokensClient(CrudClient[AccessToken]):
|
|
19
|
+
base_path = "/api/v1/access_token"
|
|
20
|
+
model = AccessToken
|
|
21
|
+
|
|
22
|
+
def create( # type: ignore[override]
|
|
23
|
+
self,
|
|
24
|
+
name: str,
|
|
25
|
+
*,
|
|
26
|
+
expires_on: Optional[str] = None,
|
|
27
|
+
) -> Tuple[str, AccessToken]:
|
|
28
|
+
"""Mint a token in the current workspace. Returns ``(raw_token, meta)``.
|
|
29
|
+
|
|
30
|
+
The raw token is shown only here — it cannot be retrieved later.
|
|
31
|
+
"""
|
|
32
|
+
body: dict = {"name": name}
|
|
33
|
+
if expires_on:
|
|
34
|
+
body["expires_on"] = expires_on
|
|
35
|
+
resp = self.session.post(f"{self.base_path}/", json=body)
|
|
36
|
+
raise_for_status(resp)
|
|
37
|
+
data = resp.json()
|
|
38
|
+
raw_token = data.get("token") if isinstance(data, dict) else None
|
|
39
|
+
if not raw_token:
|
|
40
|
+
# The raw secret is returned exactly once, at create. If it's
|
|
41
|
+
# absent the create did not behave as expected - fail loudly
|
|
42
|
+
# rather than handing back (and printing) an empty token.
|
|
43
|
+
raise PMApiError(
|
|
44
|
+
resp.status_code,
|
|
45
|
+
"access token create response missing the one-time 'token' value",
|
|
46
|
+
method="POST",
|
|
47
|
+
path=f"{self.base_path}/",
|
|
48
|
+
)
|
|
49
|
+
token = self.model.model_validate(_unwrap_result(data))
|
|
50
|
+
return raw_token, token
|
|
51
|
+
|
|
52
|
+
def revoke(self, token_id: str) -> None:
|
|
53
|
+
"""Soft-revoke one of the caller's own tokens by its hashid ``id``."""
|
|
54
|
+
self.delete(token_id)
|