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
@@ -0,0 +1,178 @@
1
+ """Shared helpers for command files (parse --data, etc.)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Union
6
+
7
+ import click
8
+
9
+ from pm.client.base import PMApiError
10
+
11
+ YES_HELP = "Skip the confirmation prompt. Required when stdin is not a terminal."
12
+
13
+
14
+ def json_option(f):
15
+ """Per-command ``--json`` so both ``pm --json report list`` and
16
+ ``pm report list --json`` work. The callback flips the same
17
+ ``ctx.obj['json']`` flag the root option sets; ``expose_value=False``
18
+ keeps the command signature unchanged."""
19
+ def _set(ctx, _param, value):
20
+ if value:
21
+ ctx.ensure_object(dict)["json"] = True
22
+ return value
23
+ return click.option(
24
+ "--json", is_flag=True, expose_value=False, callback=_set,
25
+ help="Output JSON.",
26
+ )(f)
27
+
28
+
29
+ LIMIT_HELP = "Maximum number of rows to return (default: all)."
30
+ NAME_FILTER_HELP = "Filter by name (case-insensitive substring)."
31
+
32
+ # A row field to match ``--name`` against: an attribute/key name, or a
33
+ # callable for nested values (e.g. a saved block's ``block.name``).
34
+ RowField = Union[str, Callable[[Dict[str, Any]], Any]]
35
+
36
+
37
+ def limit_option(f):
38
+ """The one ``--limit N`` every list command uses. N >= 1; omitted means
39
+ every row the command fetches, so default output is unchanged."""
40
+ return click.option(
41
+ "--limit", type=click.IntRange(min=1), default=None, help=LIMIT_HELP,
42
+ )(f)
43
+
44
+
45
+ def name_filter_option(f=None, *, matches: Optional[str] = None):
46
+ """The one ``--name TEXT`` list filter (same wording as recipient and
47
+ integration list). Exposed as ``name_filter`` so it never collides with
48
+ a command's own ``name`` parameter.
49
+
50
+ ``matches`` names the fields the filter actually reads when that is more
51
+ than the name, e.g. ``"email, first or last name"``, so the help says
52
+ what the flag does. Use bare (``@name_filter_option``) or called
53
+ (``@name_filter_option(matches=...)``).
54
+ """
55
+ help_text = (
56
+ f"Filter by {matches} (case-insensitive substring)." if matches else NAME_FILTER_HELP
57
+ )
58
+
59
+ def decorate(func):
60
+ return click.option("--name", "name_filter", default=None, help=help_text)(func)
61
+
62
+ return decorate(f) if f is not None else decorate
63
+
64
+
65
+ def _row_dict(row: Any) -> Dict[str, Any]:
66
+ if isinstance(row, dict):
67
+ return row
68
+ dump = getattr(row, "model_dump", None)
69
+ return dump() if callable(dump) else dict(getattr(row, "__dict__", {}))
70
+
71
+
72
+ def select_rows(
73
+ rows: Iterable[Any],
74
+ *,
75
+ name: Optional[str] = None,
76
+ fields: Sequence[RowField] = ("name",),
77
+ limit: Optional[int] = None,
78
+ ) -> List[Any]:
79
+ """Apply ``--name`` then ``--limit`` to rows a list command fetched.
80
+
81
+ Filtering is client-side over the rows already fetched, so it works the
82
+ same for every resource whatever filters its endpoint supports. A row
83
+ matches when any of ``fields`` contains ``name`` case-insensitively.
84
+ Order is preserved; ``limit`` keeps the first N matches.
85
+ """
86
+ out = list(rows)
87
+ if name:
88
+ needle = name.casefold()
89
+
90
+ def _matches(row: Any) -> bool:
91
+ data = _row_dict(row)
92
+ for field in fields:
93
+ value = field(data) if callable(field) else data.get(field)
94
+ if value is not None and needle in str(value).casefold():
95
+ return True
96
+ return False
97
+
98
+ out = [r for r in out if _matches(r)]
99
+ if limit is not None:
100
+ out = out[:limit]
101
+ return out
102
+
103
+
104
+ def yes_option(f):
105
+ """The one ``-y, --yes`` every destructive command uses."""
106
+ return click.option("--yes", "-y", is_flag=True, help=YES_HELP)(f)
107
+
108
+
109
+ def peek_name(fetch: Callable[[], Any], *fields: str) -> Optional[str]:
110
+ """Best-effort display name for a confirmation prompt: the first
111
+ non-empty of ``fields`` on the fetched object, or None if the fetch
112
+ fails (the prompt then shows the uid alone)."""
113
+ try:
114
+ obj = fetch()
115
+ except PMApiError:
116
+ return None
117
+ for field in fields:
118
+ value = getattr(obj, field, None)
119
+ if value:
120
+ return str(value)
121
+ return None
122
+
123
+
124
+ def confirm_delete(noun: str, uid: str, name: Optional[str] = None, *, note: str = "") -> None:
125
+ """``Delete <noun> 'name' (uid)? [y/N]`` — abort on anything but yes."""
126
+ label = f"{name!r} ({uid})" if name else uid
127
+ tail = f" {note}" if note else ""
128
+ click.confirm(f"Delete {noun} {label}?{tail}", abort=True)
129
+
130
+
131
+ def report_deleted(ctx: click.Context, noun: str, uid: str) -> None:
132
+ """Confirmation after a delete: ``{"deleted": uid}`` in JSON mode,
133
+ else ``Deleted <noun> <uid>.`` on stderr."""
134
+ if ctx.obj.get("json"):
135
+ from pm.output import emit
136
+ emit({"deleted": uid}, json_output=True)
137
+ return
138
+ info(ctx, f"Deleted {noun} {uid}.")
139
+
140
+
141
+ def info(ctx: click.Context, msg: str) -> None:
142
+ """Emit an informational message to stderr unless --quiet is set.
143
+
144
+ Use for chatty status output ("Login successful.", "Deleted X.") that
145
+ a human appreciates but a sandboxed subprocess (agent) should not see.
146
+ Error messages should keep using ``click.ClickException`` so they
147
+ always surface.
148
+ """
149
+ if ctx.obj and ctx.obj.get("quiet"):
150
+ return
151
+ click.echo(msg, err=True)
152
+
153
+
154
+ def parse_data(data_str: Optional[str]) -> Dict[str, Any]:
155
+ """Parse a `--data '<json>'` flag into a dict, or return {} if absent.
156
+
157
+ Raises ClickException with a useful message on invalid JSON.
158
+ """
159
+ if not data_str:
160
+ return {}
161
+ try:
162
+ parsed = json.loads(data_str)
163
+ except json.JSONDecodeError as exc:
164
+ raise click.ClickException(f"Invalid --data JSON: {exc}")
165
+ if not isinstance(parsed, dict):
166
+ raise click.ClickException("--data must be a JSON object")
167
+ return parsed
168
+
169
+
170
+ def merge_fields(*sources: Dict[str, Any]) -> Dict[str, Any]:
171
+ """Merge multiple field dicts, later sources override earlier. Drops keys
172
+ whose values are None so explicit flags only override when provided."""
173
+ merged: Dict[str, Any] = {}
174
+ for src in sources:
175
+ for k, v in src.items():
176
+ if v is not None:
177
+ merged[k] = v
178
+ return merged
pm/commands/auth.py ADDED
@@ -0,0 +1,222 @@
1
+ """login / logout / whoami commands."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+
7
+ import click
8
+ import requests
9
+
10
+ from pm.auth import device_flow, exchange, store
11
+ from pm.auth import token as token_auth
12
+ from pm.auth.session import PMSession
13
+ from pm.client import PMClient
14
+ from pm.client.base import PMApiError
15
+ from pm.commands._helpers import info, json_option
16
+ from pm.output import emit
17
+
18
+
19
+ def _read_token(ctx: click.Context) -> str:
20
+ """Read a PAT from stdin (when piped) or an interactive hidden prompt.
21
+
22
+ Reading from stdin / a hidden prompt keeps the token off the argv, where
23
+ it would otherwise leak into shell history and the process list.
24
+ """
25
+ if not sys.stdin.isatty():
26
+ return sys.stdin.read().strip()
27
+ return click.prompt("Personal access token (pmpat_...)", hide_input=True).strip()
28
+
29
+
30
+ def tokens_page_url(api_url: str) -> str:
31
+ """The web app page where personal access tokens are created and revoked
32
+ (frontend route ``/a/tokens``, menu label "Access Tokens")."""
33
+ return f"{api_url}/a/tokens"
34
+
35
+
36
+ def _token_login_failure(exc: token_auth.TokenExchangeError, api_url: str) -> str:
37
+ """Word a failed PAT exchange for a person: a rate limit is not a bad
38
+ token."""
39
+ if exc.status == 429:
40
+ return (
41
+ "Too many login attempts from this network address "
42
+ "(10 per minute). Wait a minute and run `pm login --token` again."
43
+ )
44
+ if exc.status is None:
45
+ # Raised before any HTTP (malformed token, unreachable host): the
46
+ # message already names the next action.
47
+ return str(exc)
48
+ return f"{exc}\nHint: tokens are created and revoked at {tokens_page_url(api_url)}"
49
+
50
+
51
+ def _login_with_token(ctx: click.Context, profile) -> None:
52
+ """Store a Personal Access Token after verifying it via the exchange
53
+ endpoint. Bypasses the Auth0 device flow entirely."""
54
+ raw = _read_token(ctx)
55
+ if not raw:
56
+ raise click.ClickException(
57
+ "No token provided. Paste the pmpat_... value at the prompt, or pipe "
58
+ "it in from your secrets manager. Create one at "
59
+ f"{tokens_page_url(profile.api_url)}."
60
+ )
61
+
62
+ # Verify before storing so we never persist a dud token.
63
+ http_session = requests.Session()
64
+ try:
65
+ token_auth.exchange_pat_for_jwt(http_session, profile.api_url, raw)
66
+ except token_auth.TokenExchangeError as exc:
67
+ raise click.ClickException(_token_login_failure(exc, profile.api_url))
68
+
69
+ token_auth.save_token(profile.name, raw)
70
+ info(ctx, f"Logged in to {profile.api_url} (profile {profile.name!r}).")
71
+ info(ctx, "Next: `pm whoami`, then `pm report list`.")
72
+
73
+
74
+ @click.command(name="login")
75
+ @click.option(
76
+ "--no-browser",
77
+ is_flag=True,
78
+ help="Don't try to open the verification URL automatically.",
79
+ )
80
+ @click.option(
81
+ "--token",
82
+ "use_token",
83
+ is_flag=True,
84
+ help=(
85
+ "Authenticate with a personal access token (recommended). The token "
86
+ "is read from a hidden prompt, or from stdin when piped from your "
87
+ "secrets manager."
88
+ ),
89
+ )
90
+ @click.pass_context
91
+ def login(ctx: click.Context, no_browser: bool, use_token: bool) -> None:
92
+ """Log in with a personal access token.
93
+
94
+ pm login --token stores the token for this profile. Without --token the
95
+ browser login is used, which needs an Auth0 client id on the profile.
96
+ """
97
+ profile = ctx.obj["profile"]
98
+
99
+ if use_token:
100
+ _login_with_token(ctx, profile)
101
+ return
102
+
103
+ if not profile.auth0_client_id:
104
+ raise click.ClickException(
105
+ f"Browser login is not set up for profile {profile.name!r}. "
106
+ f"Use a personal access token instead: create one at "
107
+ f"{tokens_page_url(profile.api_url)}, then run `pm login --token`."
108
+ )
109
+
110
+ info(ctx, f"Logging in (profile: {profile.name}, api: {profile.api_url})")
111
+
112
+ try:
113
+ device_payload = device_flow.request_device_code(
114
+ domain=profile.auth0_domain,
115
+ client_id=profile.auth0_client_id,
116
+ audience=profile.audience,
117
+ )
118
+ except device_flow.DeviceFlowError as exc:
119
+ raise click.ClickException(str(exc))
120
+
121
+ info(ctx, "")
122
+ info(
123
+ ctx,
124
+ f"Open this URL on any device: {device_payload.verification_uri_complete}",
125
+ )
126
+ info(ctx, f"Verify it shows the user code: {device_payload.user_code}")
127
+ info(ctx, "Waiting for authorization...")
128
+
129
+ try:
130
+ token = device_flow.poll_for_token(
131
+ domain=profile.auth0_domain,
132
+ client_id=profile.auth0_client_id,
133
+ payload=device_payload,
134
+ no_browser=no_browser,
135
+ )
136
+ except device_flow.DeviceFlowError as exc:
137
+ raise click.ClickException(str(exc))
138
+
139
+ http_session = requests.Session()
140
+ try:
141
+ result = exchange.exchange_for_session(
142
+ http_session, profile.api_url, token.access_token
143
+ )
144
+ except exchange.LoginError as exc:
145
+ raise click.ClickException(str(exc))
146
+
147
+ creds = store.StoredCredentials(
148
+ profile=profile.name,
149
+ api_url=profile.api_url,
150
+ auth0_refresh_token=token.refresh_token,
151
+ pm_access_token=result.pm_access_token,
152
+ pm_refresh_token=result.pm_refresh_token,
153
+ )
154
+ creds.set_cookies(http_session.cookies)
155
+ store.save(creds)
156
+
157
+ info(ctx, "Login successful.")
158
+
159
+
160
+ @click.command(name="logout")
161
+ @click.pass_context
162
+ def logout(ctx: click.Context) -> None:
163
+ """Log out and forget the stored token or session."""
164
+ profile = ctx.obj["profile"]
165
+
166
+ creds = store.load(profile.name)
167
+ if creds is not None:
168
+ try:
169
+ # Force cookie mode for the server-side logout: a stored PAT must
170
+ # not turn this into a Bearer call (there's no session to clear).
171
+ session = PMSession(profile, creds)
172
+ # Server-side logout is GET /logout/ (returns a 302 to Auth0's logout).
173
+ # We don't want to follow the redirect — Flask-Login has already
174
+ # cleared the session by the time the redirect is built.
175
+ session.get("/logout/", allow_redirects=False, timeout=10)
176
+ except Exception:
177
+ pass
178
+
179
+ store.clear(profile.name)
180
+ token_auth.clear_token(profile.name)
181
+ info(ctx, f"Logged out of profile {profile.name!r}.")
182
+ # Env credentials outlive logout: the next command would still
183
+ # authenticate with them. Always shown (not --quiet chatter).
184
+ for var in ("PM_API_TOKEN", "PM_API_JWT"):
185
+ if (os.environ.get(var) or "").strip():
186
+ click.echo(
187
+ f"Warning: {var} is set in this shell, so the next command still "
188
+ f"authenticates. Run: unset {var}",
189
+ err=True,
190
+ )
191
+
192
+
193
+ @click.command(name="whoami")
194
+ @json_option
195
+ @click.pass_context
196
+ def whoami(ctx: click.Context) -> None:
197
+ """Show who you are logged in as, and the workspace."""
198
+ profile = ctx.obj["profile"]
199
+ json_output = ctx.obj["json"]
200
+
201
+ client = PMClient.from_profile(profile)
202
+
203
+ user = client.users.current()
204
+
205
+ workspace = None
206
+ try:
207
+ workspace = client.workspaces.current()
208
+ except PMApiError:
209
+ pass
210
+
211
+ client.persist()
212
+
213
+ payload = {
214
+ "email": user.email,
215
+ "first_name": user.first_name,
216
+ "last_name": user.last_name,
217
+ "workspace": workspace.name if workspace else None,
218
+ "workspace_uid": workspace.uid if workspace else None,
219
+ "profile": profile.name,
220
+ "api_url": profile.api_url,
221
+ }
222
+ emit(payload, json_output=json_output)
pm/commands/block.py ADDED
@@ -0,0 +1,26 @@
1
+ """`pm block` — generic block CRUD plumbing (any type).
2
+
3
+ This is the canonical CRUD surface for blocks across every type. Domain
4
+ groups (`pm sql`, `pm email`, ...) cover the "create a typed block and
5
+ execute it" lifecycle; `pm block` is what you reach for to list/get/
6
+ update/delete blocks generically. `--type X` is required on `create`,
7
+ `run`, and `send` because the discriminator can't be inferred from the
8
+ group name.
9
+
10
+ Per plan §3.0 + §3.8 — and codex R3 #4 + R4 #7: `pm block delete` is
11
+ suppressed from `single_tool` agent help at the registry level (the
12
+ backend FK constraints make delete a non-guaranteed op anyway, but the
13
+ real reason is: agents shouldn't be picking blocks to delete).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from pm.commands._block_factory import make_block_group
18
+
19
+
20
+ block = make_block_group(
21
+ "block",
22
+ None,
23
+ help_text="Blocks of any type.\n\nThe typed groups (pm sql, pm email, ...) are easier; use this one to list, update or delete across types.",
24
+ crud_verbs=("get", "list", "create", "update", "delete"),
25
+ execute_verb="run",
26
+ )
pm/commands/chart.py ADDED
@@ -0,0 +1,20 @@
1
+ """`pm chart` — create + run Plotly chart blocks.
2
+
3
+ The backend's plotly executor auto-renders the PNG as a side effect of
4
+ block creation — the documented carve-out from plan §3.0's "create
5
+ doesn't execute" rule. `create_has_side_effect=True` only affects the
6
+ generated help text so the agent isn't surprised.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pm.commands._block_factory import make_block_group
11
+
12
+
13
+ chart = make_block_group(
14
+ "chart",
15
+ "plotly",
16
+ help_text="Charts (Plotly blocks).\n\nBlock type: plotly.",
17
+ crud_verbs=("create", "list", "get", "update"),
18
+ execute_verb="run",
19
+ create_has_side_effect=True,
20
+ )