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/commands/_helpers.py
ADDED
|
@@ -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
|
+
)
|