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.
Files changed (86) hide show
  1. pm/__init__.py +1 -0
  2. pm/__main__.py +4 -0
  3. pm/_alias.py +164 -0
  4. pm/auth/__init__.py +0 -0
  5. pm/auth/device_flow.py +187 -0
  6. pm/auth/exchange.py +79 -0
  7. pm/auth/jwt_cache.py +163 -0
  8. pm/auth/secret_files.py +58 -0
  9. pm/auth/session.py +440 -0
  10. pm/auth/store.py +160 -0
  11. pm/auth/token.py +289 -0
  12. pm/cli.py +282 -0
  13. pm/client/__init__.py +68 -0
  14. pm/client/access_tokens.py +54 -0
  15. pm/client/base.py +229 -0
  16. pm/client/blocks.py +461 -0
  17. pm/client/databases.py +147 -0
  18. pm/client/favorites.py +14 -0
  19. pm/client/folders.py +10 -0
  20. pm/client/integrations.py +14 -0
  21. pm/client/organizations.py +20 -0
  22. pm/client/recipients.py +92 -0
  23. pm/client/reports.py +148 -0
  24. pm/client/roles.py +100 -0
  25. pm/client/runs.py +134 -0
  26. pm/client/saved_blocks.py +13 -0
  27. pm/client/tableau.py +108 -0
  28. pm/client/users.py +71 -0
  29. pm/client/webhooks.py +43 -0
  30. pm/client/workspaces.py +34 -0
  31. pm/commands/__init__.py +0 -0
  32. pm/commands/_block_factory.py +762 -0
  33. pm/commands/_helpers.py +178 -0
  34. pm/commands/auth.py +222 -0
  35. pm/commands/block.py +26 -0
  36. pm/commands/chart.py +20 -0
  37. pm/commands/db.py +291 -0
  38. pm/commands/email.py +13 -0
  39. pm/commands/explore.py +20 -0
  40. pm/commands/favorites.py +77 -0
  41. pm/commands/folders.py +113 -0
  42. pm/commands/help.py +643 -0
  43. pm/commands/integrations.py +95 -0
  44. pm/commands/metrics_view.py +195 -0
  45. pm/commands/organizations.py +56 -0
  46. pm/commands/recipient.py +226 -0
  47. pm/commands/reports.py +364 -0
  48. pm/commands/roles.py +102 -0
  49. pm/commands/runs.py +214 -0
  50. pm/commands/saved_blocks.py +97 -0
  51. pm/commands/sftp.py +19 -0
  52. pm/commands/slack.py +13 -0
  53. pm/commands/slides.py +216 -0
  54. pm/commands/sql.py +17 -0
  55. pm/commands/tableau.py +148 -0
  56. pm/commands/token.py +107 -0
  57. pm/commands/users.py +262 -0
  58. pm/commands/webhooks.py +132 -0
  59. pm/commands/workspace.py +156 -0
  60. pm/config.py +192 -0
  61. pm/errors.py +228 -0
  62. pm/log.py +63 -0
  63. pm/models/__init__.py +31 -0
  64. pm/models/access_tokens.py +25 -0
  65. pm/models/blocks.py +21 -0
  66. pm/models/databases.py +15 -0
  67. pm/models/favorites.py +14 -0
  68. pm/models/folders.py +14 -0
  69. pm/models/integrations.py +20 -0
  70. pm/models/organizations.py +13 -0
  71. pm/models/recipients.py +41 -0
  72. pm/models/reports.py +16 -0
  73. pm/models/roles.py +22 -0
  74. pm/models/runs.py +56 -0
  75. pm/models/saved_blocks.py +15 -0
  76. pm/models/users.py +36 -0
  77. pm/models/webhooks.py +23 -0
  78. pm/models/workspaces.py +16 -0
  79. pm/output.py +92 -0
  80. pm/types.py +130 -0
  81. pm/validators.py +499 -0
  82. pushmetrics_cli-0.1.0.dist-info/METADATA +221 -0
  83. pushmetrics_cli-0.1.0.dist-info/RECORD +86 -0
  84. pushmetrics_cli-0.1.0.dist-info/WHEEL +5 -0
  85. pushmetrics_cli-0.1.0.dist-info/entry_points.txt +2 -0
  86. 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)