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/commands/users.py ADDED
@@ -0,0 +1,262 @@
1
+ """User commands."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ import click
7
+
8
+ from pm._alias import AliasedGroup
9
+ from pm.client import PMClient
10
+ from pm.commands._helpers import json_option, peek_name, yes_option, limit_option, name_filter_option, select_rows
11
+ from pm.output import emit
12
+
13
+
14
+ # pm-tool: REST-aligned verbs are canonical; legacy `add`/`remove`/`ls` keep
15
+ # working as compatibility aliases so existing scripts don't break.
16
+ @click.group(
17
+ name="users",
18
+ cls=AliasedGroup,
19
+ aliases={
20
+ # legacy → canonical: old form keeps working, canonical is primary
21
+ "ls": "list",
22
+ "add": "create",
23
+ "remove": "delete",
24
+ },
25
+ )
26
+ def users() -> None:
27
+ """Users in the current workspace."""
28
+
29
+
30
+ def _role_name(role) -> str:
31
+ if role is None:
32
+ return ""
33
+ if hasattr(role, "model_dump"):
34
+ role = role.model_dump()
35
+ if isinstance(role, dict):
36
+ return role.get("name") or str(role.get("id", ""))
37
+ return str(role)
38
+
39
+
40
+ def _format_roles(roles) -> str:
41
+ if not roles:
42
+ return ""
43
+ return ", ".join(_role_name(r) for r in roles)
44
+
45
+
46
+ @users.command(name="list")
47
+ @name_filter_option(matches="email, first or last name")
48
+ @limit_option
49
+ @json_option
50
+ @click.pass_context
51
+ def ls(ctx: click.Context, name_filter: Optional[str], limit: Optional[int]) -> None:
52
+ """List users in the current workspace."""
53
+ profile = ctx.obj["profile"]
54
+ json_output = ctx.obj["json"]
55
+
56
+ client = PMClient.from_profile(profile)
57
+ items = select_rows(
58
+ client.users.list(),
59
+ name=name_filter,
60
+ fields=("email", "first_name", "last_name"),
61
+ limit=limit,
62
+ )
63
+ client.persist()
64
+
65
+ emit(
66
+ items,
67
+ json_output=json_output,
68
+ columns=[
69
+ ("UID", "uid"),
70
+ ("EMAIL", "email"),
71
+ ("FIRST", "first_name"),
72
+ ("LAST", "last_name"),
73
+ ("MAIN_ROLE", lambda r: _role_name(r.get("main_role"))),
74
+ ("ROLES", lambda r: _format_roles(r.get("roles"))),
75
+ ("LAST_LOGIN", "last_login"),
76
+ ],
77
+ )
78
+
79
+
80
+ @users.command(name="create")
81
+ @click.argument("email")
82
+ @click.option("--first-name", default="", help="First name.")
83
+ @click.option("--last-name", default="", help="Last name.")
84
+ @click.option(
85
+ "--role-id",
86
+ "role_ids",
87
+ type=int,
88
+ multiple=True,
89
+ help=(
90
+ "Numeric role id to assign (from pm role list). Without it the "
91
+ "workspace's default roles apply. Repeatable."
92
+ ),
93
+ )
94
+ @click.option(
95
+ "--skip-welcome-email",
96
+ is_flag=True,
97
+ help="Do not send the invitation email.",
98
+ )
99
+ @json_option
100
+ @click.pass_context
101
+ def add(
102
+ ctx: click.Context,
103
+ email: str,
104
+ first_name: str,
105
+ last_name: str,
106
+ role_ids: tuple,
107
+ skip_welcome_email: bool,
108
+ ) -> None:
109
+ """Invite a user to the current workspace.
110
+
111
+ An existing user of the organization is added to the workspace; a
112
+ new address gets an account and a welcome email (unless
113
+ --skip-welcome-email).
114
+ """
115
+ profile = ctx.obj["profile"]
116
+ json_output = ctx.obj["json"]
117
+
118
+ payload = {
119
+ "email": email,
120
+ "first_name": first_name,
121
+ "last_name": last_name,
122
+ "roles": list(role_ids),
123
+ }
124
+
125
+ client = PMClient.from_profile(profile)
126
+ user, action = client.users.create(payload, skip_welcome_email=skip_welcome_email)
127
+ client.persist()
128
+
129
+ if json_output:
130
+ out = user.model_dump()
131
+ if action is not None:
132
+ out["action"] = action
133
+ emit(out, json_output=True)
134
+ return
135
+
136
+ # Map the backend's action to a clear one-line outcome so callers
137
+ # don't have to compare workspace member lists themselves.
138
+ label = {
139
+ "created": "created",
140
+ "added_to_workspace": "added existing user to workspace",
141
+ "no_op": "already in this workspace (no change)",
142
+ }.get(action or "", "ok")
143
+ click.echo(f"{label}: {user.uid} {user.email} roles={_format_roles(user.roles)}")
144
+
145
+
146
+ @users.command(name="delete")
147
+ @click.argument("user_uid")
148
+ @click.option(
149
+ "--workspace",
150
+ "workspace_uid",
151
+ default=None,
152
+ help="Workspace uid to remove the user from (default: the active workspace).",
153
+ )
154
+ @yes_option
155
+ @json_option
156
+ @click.pass_context
157
+ def remove(
158
+ ctx: click.Context,
159
+ user_uid: str,
160
+ workspace_uid: Optional[str],
161
+ yes: bool,
162
+ ) -> None:
163
+ """Remove a user from a workspace.
164
+
165
+ The user loses their roles in that workspace. If it was their last
166
+ workspace, the account is deactivated, removed from the organization
167
+ and its email anonymised. This is the way to offboard a user.
168
+ """
169
+ profile = ctx.obj["profile"]
170
+ json_output = ctx.obj["json"]
171
+
172
+ if not yes:
173
+ label = peek_name(
174
+ lambda: PMClient.from_profile(profile).users.get(user_uid), "email"
175
+ ) or user_uid
176
+ target = f"workspace {workspace_uid}" if workspace_uid else "the active workspace"
177
+ click.confirm(
178
+ f"Remove user {label!r} from {target}? "
179
+ "If this is their last workspace they will be deactivated.",
180
+ abort=True,
181
+ )
182
+
183
+ client = PMClient.from_profile(profile)
184
+ client.users.remove_from_workspace(user_uid, workspace_uid)
185
+ client.persist()
186
+
187
+ if json_output:
188
+ emit(
189
+ {
190
+ "removed_user": user_uid,
191
+ "workspace": workspace_uid or "active",
192
+ },
193
+ json_output=True,
194
+ )
195
+ return
196
+ click.echo(f"Removed user {user_uid} from {workspace_uid or 'the active workspace'}.", err=True)
197
+
198
+
199
+ @users.command(name="set-role")
200
+ @click.argument("user_uid")
201
+ @click.argument("main_role", type=click.Choice(["admin", "user", "guest"], case_sensitive=False))
202
+ @json_option
203
+ @click.pass_context
204
+ def set_role(ctx: click.Context, user_uid: str, main_role: str) -> None:
205
+ """Set a user's main role in the current workspace.
206
+
207
+ Replaces the current main role; group memberships are unchanged.
208
+ """
209
+ profile = ctx.obj["profile"]
210
+ json_output = ctx.obj["json"]
211
+
212
+ client = PMClient.from_profile(profile)
213
+ role_id = client.roles.find_main_role_id(main_role)
214
+ user = client.users.update(user_uid, {"main_role": role_id})
215
+ client.persist()
216
+
217
+ if json_output:
218
+ emit(user, json_output=True)
219
+ return
220
+ emit(
221
+ {
222
+ "uid": user.uid,
223
+ "email": user.email,
224
+ "main_role": _role_name(user.main_role),
225
+ "roles": _format_roles(user.roles),
226
+ },
227
+ json_output=False,
228
+ )
229
+
230
+
231
+ @users.command(name="get")
232
+ @click.argument("user_id", metavar="USER_REF")
233
+ @json_option
234
+ @click.pass_context
235
+ def get(ctx: click.Context, user_id: str) -> None:
236
+ """Show one user (use --json for the full record).
237
+
238
+ USER_REF is the user's uid or numeric id.
239
+ """
240
+ profile = ctx.obj["profile"]
241
+ json_output = ctx.obj["json"]
242
+
243
+ client = PMClient.from_profile(profile)
244
+ user = client.users.get(user_id)
245
+ client.persist()
246
+
247
+ if json_output:
248
+ emit(user, json_output=True)
249
+ else:
250
+ emit(
251
+ {
252
+ "id": user.id,
253
+ "uid": user.uid,
254
+ "email": user.email,
255
+ "first_name": user.first_name,
256
+ "last_name": user.last_name,
257
+ "main_role": _role_name(user.main_role),
258
+ "roles": _format_roles(user.roles),
259
+ "last_login": user.last_login,
260
+ },
261
+ json_output=False,
262
+ )
@@ -0,0 +1,132 @@
1
+ """Webhook commands (full CRUD).
2
+
3
+ The backend JSON-stringifies `details`/`recipients`/`slack_recipients` on input;
4
+ the client handles that. Pass these via `--data '<json>'` for structured input,
5
+ or as plain strings via the explicit flags.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ import click
12
+
13
+ from pm._alias import AliasedGroup
14
+ from pm.client import PMClient
15
+ from pm.commands._helpers import (
16
+ confirm_delete,
17
+ json_option,
18
+ merge_fields,
19
+ parse_data,
20
+ peek_name,
21
+ report_deleted,
22
+ yes_option,
23
+ limit_option,
24
+ select_rows,
25
+ )
26
+ from pm.output import emit
27
+
28
+
29
+ @click.group(name="webhooks", cls=AliasedGroup, aliases={"ls": "list"})
30
+ def webhooks() -> None:
31
+ """Webhooks that trigger report runs."""
32
+
33
+
34
+ @webhooks.command(name="list")
35
+ @limit_option
36
+ @json_option
37
+ @click.pass_context
38
+ def ls(ctx: click.Context, limit: Optional[int]) -> None:
39
+ """List webhooks (ids only).
40
+
41
+ The list shows each webhook's numeric id; pm webhook get <id> shows
42
+ the rest.
43
+ """
44
+ client = PMClient.from_profile(ctx.obj["profile"])
45
+ items = select_rows(client.webhooks.list(), limit=limit)
46
+ client.persist()
47
+ emit(
48
+ items,
49
+ json_output=ctx.obj["json"],
50
+ # Only `id` is meaningful — other columns are null until backend adds list_columns.
51
+ columns=[("ID", "id")],
52
+ )
53
+
54
+
55
+ @webhooks.command(name="get")
56
+ @click.argument("uid")
57
+ @json_option
58
+ @click.pass_context
59
+ def get(ctx: click.Context, uid: str) -> None:
60
+ """Show one webhook."""
61
+ client = PMClient.from_profile(ctx.obj["profile"])
62
+ wh = client.webhooks.get(uid)
63
+ client.persist()
64
+ emit(wh, json_output=ctx.obj["json"])
65
+
66
+
67
+ @webhooks.command(name="create")
68
+ @click.option("--report-id", type=int, required=True,
69
+ help="Numeric id of the report to trigger (from pm report get --json).")
70
+ @click.option("--token", default=None,
71
+ help="Secret the caller must present. Generated if omitted.")
72
+ @click.option("--active/--inactive", default=True, show_default=True,
73
+ help="Whether the webhook accepts calls.")
74
+ @click.option("--data", default=None, help="Extra fields (details, recipients, slack_recipients) as a JSON object.")
75
+ @json_option
76
+ @click.pass_context
77
+ def create(
78
+ ctx: click.Context,
79
+ report_id: int,
80
+ token: Optional[str],
81
+ active: bool,
82
+ data: Optional[str],
83
+ ) -> None:
84
+ """Create a webhook for a report."""
85
+ payload = merge_fields(
86
+ parse_data(data),
87
+ {"report_id": report_id, "token": token, "active": active},
88
+ )
89
+ client = PMClient.from_profile(ctx.obj["profile"])
90
+ wh = client.webhooks.create(payload)
91
+ client.persist()
92
+ emit(wh, json_output=ctx.obj["json"])
93
+
94
+
95
+ @webhooks.command(name="update")
96
+ @click.argument("uid")
97
+ @click.option("--token", default=None, help="Replace the secret the caller must present.")
98
+ @click.option("--active/--inactive", default=None, help="Enable or disable the webhook.")
99
+ @click.option("--data", default=None, help="Extra fields as a JSON object.")
100
+ @json_option
101
+ @click.pass_context
102
+ def update(
103
+ ctx: click.Context,
104
+ uid: str,
105
+ token: Optional[str],
106
+ active: Optional[bool],
107
+ data: Optional[str],
108
+ ) -> None:
109
+ """Update a webhook."""
110
+ payload = merge_fields(parse_data(data), {"token": token, "active": active})
111
+ if not payload:
112
+ raise click.UsageError("Nothing to update; pass --token, --active/--inactive, or --data.")
113
+ client = PMClient.from_profile(ctx.obj["profile"])
114
+ wh = client.webhooks.update(uid, payload)
115
+ client.persist()
116
+ emit(wh, json_output=ctx.obj["json"])
117
+
118
+
119
+ @webhooks.command(name="delete")
120
+ @click.argument("uid")
121
+ @yes_option
122
+ @json_option
123
+ @click.pass_context
124
+ def delete(ctx: click.Context, uid: str, yes: bool) -> None:
125
+ """Delete a webhook."""
126
+ if not yes:
127
+ name = peek_name(lambda: PMClient.from_profile(ctx.obj["profile"]).webhooks.get(uid), "name")
128
+ confirm_delete("webhook", uid, name)
129
+ client = PMClient.from_profile(ctx.obj["profile"])
130
+ client.webhooks.delete(uid)
131
+ client.persist()
132
+ report_deleted(ctx, "webhook", uid)
@@ -0,0 +1,156 @@
1
+ """Workspace commands."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ import click
7
+
8
+ from pm._alias import AliasedGroup
9
+ from pm.client import PMClient
10
+ from pm.commands._helpers import (
11
+ confirm_delete,
12
+ json_option,
13
+ peek_name,
14
+ report_deleted,
15
+ yes_option,
16
+ limit_option,
17
+ name_filter_option,
18
+ select_rows,
19
+ )
20
+ from pm.output import emit
21
+
22
+
23
+ @click.group(name="workspace", cls=AliasedGroup, aliases={"ls": "list"})
24
+ def workspace() -> None:
25
+ """Workspaces you belong to."""
26
+
27
+
28
+ @workspace.command(name="list")
29
+ @name_filter_option
30
+ @limit_option
31
+ @json_option
32
+ @click.pass_context
33
+ def ls(ctx: click.Context, name_filter: Optional[str], limit: Optional[int]) -> None:
34
+ """List workspaces you have access to."""
35
+ profile = ctx.obj["profile"]
36
+ json_output = ctx.obj["json"]
37
+
38
+ client = PMClient.from_profile(profile)
39
+ workspaces_list = select_rows(client.workspaces.list(), name=name_filter, limit=limit)
40
+ client.persist()
41
+
42
+ emit(
43
+ workspaces_list,
44
+ json_output=json_output,
45
+ columns=[
46
+ ("ACTIVE", lambda r: "*" if r.get("is_active") else ""),
47
+ ("UID", "uid"),
48
+ ("NAME", "name"),
49
+ ("ORGANIZATION", lambda r: (r.get("organization") or {}).get("name") if isinstance(r.get("organization"), dict) else None),
50
+ ],
51
+ )
52
+
53
+
54
+ @workspace.command(name="current")
55
+ @json_option
56
+ @click.pass_context
57
+ def current(ctx: click.Context) -> None:
58
+ """Show the active workspace."""
59
+ profile = ctx.obj["profile"]
60
+ json_output = ctx.obj["json"]
61
+
62
+ client = PMClient.from_profile(profile)
63
+ ws = client.workspaces.current()
64
+ client.persist()
65
+
66
+ emit(
67
+ {
68
+ "uid": ws.uid,
69
+ "name": ws.name,
70
+ "id": ws.id,
71
+ },
72
+ json_output=json_output,
73
+ )
74
+
75
+
76
+ @workspace.command(name="switch")
77
+ @click.argument("uid")
78
+ @json_option
79
+ @click.pass_context
80
+ def switch(ctx: click.Context, uid: str) -> None:
81
+ """Switch the active workspace (browser login only).
82
+
83
+ Not available with a personal access token: a token is bound to the
84
+ workspace it was created in. To work in another workspace, create a
85
+ token there and run pm login --token with it.
86
+ """
87
+ profile = ctx.obj["profile"]
88
+ json_output = ctx.obj["json"]
89
+
90
+ client = PMClient.from_profile(profile)
91
+ ws = client.workspaces.switch(uid)
92
+
93
+ emit(
94
+ {
95
+ "switched_to": ws.name,
96
+ "uid": ws.uid,
97
+ },
98
+ json_output=json_output,
99
+ )
100
+
101
+
102
+ @workspace.command(name="delete")
103
+ @click.argument("uid")
104
+ @yes_option
105
+ @json_option
106
+ @click.pass_context
107
+ def delete(ctx: click.Context, uid: str, yes: bool) -> None:
108
+ """Delete a workspace and everything in it.
109
+
110
+ All reports, agents and roles in the workspace are removed. This
111
+ cannot be undone. You cannot delete the workspace you are working in,
112
+ or your only workspace.
113
+ """
114
+ profile = ctx.obj["profile"]
115
+
116
+ if not yes:
117
+ name = peek_name(lambda: PMClient.from_profile(profile).workspaces.get(uid), "name")
118
+ confirm_delete(
119
+ "workspace", uid, name,
120
+ note="All reports, agents and roles in it will be gone.",
121
+ )
122
+
123
+ client = PMClient.from_profile(profile)
124
+ client.workspaces.delete(uid)
125
+ client.persist()
126
+ report_deleted(ctx, "workspace", uid)
127
+
128
+
129
+ @workspace.command(name="create")
130
+ @click.argument("name")
131
+ @json_option
132
+ @click.pass_context
133
+ def create(ctx: click.Context, name: str) -> None:
134
+ """Create a workspace in your organization.
135
+
136
+ You become its first member; it starts with the default roles and a
137
+ default agent.
138
+ """
139
+ profile = ctx.obj["profile"]
140
+ json_output = ctx.obj["json"]
141
+
142
+ client = PMClient.from_profile(profile)
143
+ ws = client.workspaces.create({"name": name})
144
+ client.persist()
145
+
146
+ if json_output:
147
+ emit(ws, json_output=True)
148
+ return
149
+ emit(
150
+ {
151
+ "uid": ws.uid,
152
+ "name": ws.name,
153
+ "id": ws.id,
154
+ },
155
+ json_output=False,
156
+ )