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/db.py ADDED
@@ -0,0 +1,291 @@
1
+ """Database 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, limit_option, name_filter_option, select_rows
11
+ from pm.output import emit
12
+
13
+
14
+ @click.group(name="db", cls=AliasedGroup, aliases={"ls": "list"})
15
+ def db() -> None:
16
+ """Databases, their schemas and tables.
17
+
18
+ DB_REF is the database uid or name.
19
+ """
20
+
21
+
22
+ @db.command(name="list")
23
+ @name_filter_option
24
+ @limit_option
25
+ @json_option
26
+ @click.pass_context
27
+ def ls(ctx: click.Context, name_filter: Optional[str], limit: Optional[int]) -> None:
28
+ """List databases configured in the current workspace."""
29
+ profile = ctx.obj["profile"]
30
+ json_output = ctx.obj["json"]
31
+
32
+ client = PMClient.from_profile(profile)
33
+ items = select_rows(
34
+ client.databases.list(), name=name_filter, fields=("database_name",), limit=limit
35
+ )
36
+ client.persist()
37
+
38
+ emit(
39
+ items,
40
+ json_output=json_output,
41
+ columns=[
42
+ ("UID", "uid"),
43
+ ("NAME", "database_name"),
44
+ ("BACKEND", "backend"),
45
+ ],
46
+ )
47
+
48
+
49
+ @db.command(name="schemas")
50
+ @click.argument("db_ref")
51
+ @click.option(
52
+ "--force",
53
+ is_flag=True,
54
+ help="Bypass the cached schema list and refresh from the source.",
55
+ )
56
+ @json_option
57
+ @click.pass_context
58
+ def schemas(ctx: click.Context, db_ref: str, force: bool) -> None:
59
+ """List the schemas in a database."""
60
+ profile = ctx.obj["profile"]
61
+ json_output = ctx.obj["json"]
62
+
63
+ client = PMClient.from_profile(profile)
64
+ names = client.databases.schemas(db_ref, force=force)
65
+ client.persist()
66
+
67
+ if json_output:
68
+ emit(names, json_output=True)
69
+ return
70
+
71
+ emit(
72
+ [{"name": s} for s in names],
73
+ json_output=False,
74
+ columns=[("SCHEMA", "name")],
75
+ )
76
+
77
+
78
+ @db.command(name="tables")
79
+ @click.argument("db_ref")
80
+ @click.argument("schema")
81
+ @json_option
82
+ @click.pass_context
83
+ def tables(ctx: click.Context, db_ref: str, schema: str) -> None:
84
+ """List the tables and views in a schema."""
85
+ profile = ctx.obj["profile"]
86
+ json_output = ctx.obj["json"]
87
+
88
+ client = PMClient.from_profile(profile)
89
+ items = client.databases.tables(db_ref, schema)
90
+ client.persist()
91
+
92
+ emit(
93
+ items,
94
+ json_output=json_output,
95
+ columns=[
96
+ ("NAME", "name"),
97
+ ("TYPE", "type"),
98
+ ],
99
+ )
100
+
101
+
102
+ @db.command(name="describe")
103
+ @click.argument("db_ref")
104
+ @click.argument("schema")
105
+ @click.argument("table")
106
+ @json_option
107
+ @click.pass_context
108
+ def describe(ctx: click.Context, db_ref: str, schema: str, table: str) -> None:
109
+ """Show a table's columns, keys and indexes."""
110
+ profile = ctx.obj["profile"]
111
+ json_output = ctx.obj["json"]
112
+
113
+ client = PMClient.from_profile(profile)
114
+ meta = client.databases.table_metadata(db_ref, table, schema)
115
+ client.persist()
116
+
117
+ if json_output:
118
+ emit(meta, json_output=True)
119
+ return
120
+
121
+ click.echo(f"Table: {schema}.{table}")
122
+ if meta.get("comment"):
123
+ click.echo(f"Comment: {meta['comment']}")
124
+
125
+ cols = meta.get("columns") or []
126
+ if cols:
127
+ click.echo("")
128
+ emit(
129
+ cols,
130
+ json_output=False,
131
+ columns=[
132
+ ("NAME", "name"),
133
+ ("TYPE", "longType"),
134
+ ("KEYS", lambda r: ",".join(
135
+ k.get("type", "") for k in (r.get("keys") or [])
136
+ )),
137
+ ("COMMENT", lambda r: r.get("comment") or ""),
138
+ ],
139
+ )
140
+
141
+ fks = meta.get("foreignKeys") or []
142
+ if fks:
143
+ click.echo("")
144
+ click.echo("Foreign keys:")
145
+ for fk in fks:
146
+ src = ", ".join(fk.get("column_names") or [])
147
+ ref_table = fk.get("referred_table") or "?"
148
+ ref_cols = ", ".join(fk.get("referred_columns") or [])
149
+ ref_schema = fk.get("referred_schema") or ""
150
+ ref_full = f"{ref_schema}.{ref_table}" if ref_schema else ref_table
151
+ click.echo(f" ({src}) -> {ref_full}({ref_cols})")
152
+
153
+
154
+ @db.command(name="select-star")
155
+ @click.argument("db_ref")
156
+ @click.argument("schema")
157
+ @click.argument("table")
158
+ @click.option(
159
+ "--limit",
160
+ type=int,
161
+ default=100,
162
+ show_default=True,
163
+ help="Row limit written into the generated SQL (max 10000).",
164
+ )
165
+ @json_option
166
+ @click.pass_context
167
+ def select_star(
168
+ ctx: click.Context, db_ref: str, schema: str, table: str, limit: int
169
+ ) -> None:
170
+ """Print a SELECT * statement for a table (not run)."""
171
+ profile = ctx.obj["profile"]
172
+ json_output = ctx.obj["json"]
173
+
174
+ client = PMClient.from_profile(profile)
175
+ sql = client.databases.select_star_sql(db_ref, table, schema, limit=limit)
176
+ client.persist()
177
+
178
+ if json_output:
179
+ emit({"sql": sql}, json_output=True)
180
+ return
181
+ click.echo(sql)
182
+
183
+
184
+ @db.command(name="exists")
185
+ @click.argument("db_ref")
186
+ @click.argument("schema")
187
+ @click.argument("table")
188
+ @json_option
189
+ @click.pass_context
190
+ def exists(ctx: click.Context, db_ref: str, schema: str, table: str) -> None:
191
+ """Check whether a table or view exists.
192
+
193
+ Exits 0 if it exists and 1 if not, so it works in shell conditions:
194
+ if pm database exists ...; then ...; fi. With --json the exit code is
195
+ always 0 and the "exists" field carries the answer.
196
+ """
197
+ profile = ctx.obj["profile"]
198
+ json_output = ctx.obj["json"]
199
+
200
+ client = PMClient.from_profile(profile)
201
+ info = client.databases.table_exists(db_ref, table, schema)
202
+ client.persist()
203
+
204
+ if json_output:
205
+ emit(info, json_output=True)
206
+ # In --json mode (agent / programmatic), exit cleanly — the JSON
207
+ # carries the boolean. Exit-code signalling is reserved for shell
208
+ # use of the human-readable mode below.
209
+ return
210
+ kind = info.get("type") or "-"
211
+ resolved = info.get("resolved_name") or "-"
212
+ click.echo(f"exists={info.get('exists')} type={kind} resolved={resolved}")
213
+ if not info.get("exists"):
214
+ ctx.exit(1)
215
+
216
+
217
+ @db.command(name="functions")
218
+ @click.argument("db_ref")
219
+ @click.option(
220
+ "--filter",
221
+ "filter_",
222
+ default="",
223
+ help="Keep only names containing this text (case-insensitive).",
224
+ )
225
+ @click.option(
226
+ "--limit",
227
+ type=int,
228
+ default=100,
229
+ show_default=True,
230
+ help="Maximum number of names to return (max 500).",
231
+ )
232
+ @json_option
233
+ @click.pass_context
234
+ def functions(ctx: click.Context, db_ref: str, filter_: str, limit: int) -> None:
235
+ """List the SQL functions the database supports."""
236
+ profile = ctx.obj["profile"]
237
+ json_output = ctx.obj["json"]
238
+
239
+ client = PMClient.from_profile(profile)
240
+ result = client.databases.function_names(db_ref, filter=filter_, limit=limit)
241
+ client.persist()
242
+
243
+ if json_output:
244
+ emit(result, json_output=True)
245
+ return
246
+
247
+ note = result.get("note")
248
+ if note:
249
+ click.echo(f"Note: {note}")
250
+ return
251
+
252
+ for name in result.get("functions") or []:
253
+ click.echo(name)
254
+ total = result.get("total", 0)
255
+ count = result.get("count", 0)
256
+ if total and count < total:
257
+ click.echo(f"-- truncated: {count} of {total} (pass --limit to widen).")
258
+
259
+
260
+ @db.command(name="search")
261
+ @click.argument("db_ref")
262
+ @click.argument("pattern")
263
+ @click.option(
264
+ "--schema",
265
+ default="",
266
+ help="Search one schema only (default: every non-system schema).",
267
+ )
268
+ @json_option
269
+ @click.pass_context
270
+ def search(ctx: click.Context, db_ref: str, pattern: str, schema: str) -> None:
271
+ """Find tables and views whose name contains PATTERN."""
272
+ profile = ctx.obj["profile"]
273
+ json_output = ctx.obj["json"]
274
+
275
+ client = PMClient.from_profile(profile)
276
+ result = client.databases.search_tables(db_ref, pattern, schema=schema)
277
+ client.persist()
278
+
279
+ if json_output:
280
+ emit(result, json_output=True)
281
+ return
282
+
283
+ emit(
284
+ result.get("results") or [],
285
+ json_output=False,
286
+ columns=[
287
+ ("SCHEMA", "schema"),
288
+ ("NAME", "name"),
289
+ ("TYPE", "type"),
290
+ ],
291
+ )
pm/commands/email.py ADDED
@@ -0,0 +1,13 @@
1
+ """`pm email` — create + send email blocks."""
2
+ from __future__ import annotations
3
+
4
+ from pm.commands._block_factory import make_block_group
5
+
6
+
7
+ email = make_block_group(
8
+ "email",
9
+ "email",
10
+ help_text="Email blocks.\n\nBlock type: email.",
11
+ crud_verbs=("create", "list", "get"),
12
+ execute_verb="send",
13
+ )
pm/commands/explore.py ADDED
@@ -0,0 +1,20 @@
1
+ """`pm explore` — create explore (saved-view) blocks.
2
+
3
+ No execute verb today — explore blocks are saved-view definitions that
4
+ the UI renders; there's no server-side "run" path that produces a
5
+ deliverable artifact. ``list`` and ``get`` are exposed for parity with
6
+ the other typed-block groups so an agent that guesses ``pm explore
7
+ list`` lands on a working command instead of an error envelope (prod
8
+ session Gn6eNEko2y, 2026-06-09).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from pm.commands._block_factory import make_block_group
13
+
14
+
15
+ explore = make_block_group(
16
+ "explore",
17
+ "explore",
18
+ help_text="Explore blocks (saved data views).\n\nBlock type: explore.",
19
+ crud_verbs=("create", "list", "get", "update"),
20
+ )
@@ -0,0 +1,77 @@
1
+ """Favorite commands (ls/create/delete only — backend supports more but FE doesn't)."""
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
+ merge_fields,
14
+ parse_data,
15
+ report_deleted,
16
+ yes_option,
17
+ limit_option,
18
+ select_rows,
19
+ )
20
+ from pm.output import emit
21
+
22
+
23
+ @click.group(name="favorites", cls=AliasedGroup, aliases={"ls": "list"})
24
+ def favorites() -> None:
25
+ """Your favorite reports and other objects."""
26
+
27
+
28
+ @favorites.command(name="list")
29
+ @limit_option
30
+ @json_option
31
+ @click.pass_context
32
+ def ls(ctx: click.Context, limit: Optional[int]) -> None:
33
+ """List your favorites."""
34
+ client = PMClient.from_profile(ctx.obj["profile"])
35
+ items = select_rows(client.favorites.list(), limit=limit)
36
+ client.persist()
37
+ emit(
38
+ items,
39
+ json_output=ctx.obj["json"],
40
+ columns=[
41
+ ("UID", "uid"),
42
+ ("CLASS", "class_name"),
43
+ ("OBJ_ID", "obj_id"),
44
+ ],
45
+ )
46
+
47
+
48
+ @favorites.command(name="create")
49
+ @click.option("--class-name", required=True,
50
+ help="Kind of object to favorite, e.g. Report.")
51
+ @click.option("--obj-id", type=int, required=True,
52
+ help="Numeric id of the object (from pm report get --json).")
53
+ @click.option("--data", default=None, help="Extra fields as a JSON object.")
54
+ @json_option
55
+ @click.pass_context
56
+ def create(ctx: click.Context, class_name: str, obj_id: int, data: Optional[str]) -> None:
57
+ """Add an object to your favorites."""
58
+ payload = merge_fields(parse_data(data), {"class_name": class_name, "obj_id": obj_id})
59
+ client = PMClient.from_profile(ctx.obj["profile"])
60
+ fav = client.favorites.create(payload)
61
+ client.persist()
62
+ emit(fav, json_output=ctx.obj["json"])
63
+
64
+
65
+ @favorites.command(name="delete")
66
+ @click.argument("uid")
67
+ @yes_option
68
+ @json_option
69
+ @click.pass_context
70
+ def delete(ctx: click.Context, uid: str, yes: bool) -> None:
71
+ """Remove a favorite."""
72
+ if not yes:
73
+ confirm_delete("favorite", uid)
74
+ client = PMClient.from_profile(ctx.obj["profile"])
75
+ client.favorites.delete(uid)
76
+ client.persist()
77
+ report_deleted(ctx, "favorite", uid)
pm/commands/folders.py ADDED
@@ -0,0 +1,113 @@
1
+ """Folder commands (full CRUD)."""
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
+ merge_fields,
14
+ parse_data,
15
+ peek_name,
16
+ report_deleted,
17
+ yes_option,
18
+ limit_option,
19
+ name_filter_option,
20
+ select_rows,
21
+ )
22
+ from pm.output import emit
23
+
24
+
25
+ @click.group(name="folders", cls=AliasedGroup, aliases={"ls": "list"})
26
+ def folders() -> None:
27
+ """Folders that organize reports."""
28
+
29
+
30
+ @folders.command(name="list")
31
+ @name_filter_option
32
+ @limit_option
33
+ @json_option
34
+ @click.pass_context
35
+ def ls(ctx: click.Context, name_filter: Optional[str], limit: Optional[int]) -> None:
36
+ """List folders in the current workspace."""
37
+ client = PMClient.from_profile(ctx.obj["profile"])
38
+ items = select_rows(client.folders.list(), name=name_filter, limit=limit)
39
+ client.persist()
40
+ emit(
41
+ items,
42
+ json_output=ctx.obj["json"],
43
+ columns=[("UID", "uid"), ("NAME", "name"), ("PARENT_ID", "parent_id"), ("ID", "id")],
44
+ )
45
+
46
+
47
+ @folders.command(name="get")
48
+ @click.argument("uid")
49
+ @json_option
50
+ @click.pass_context
51
+ def get(ctx: click.Context, uid: str) -> None:
52
+ """Show one folder."""
53
+ client = PMClient.from_profile(ctx.obj["profile"])
54
+ folder = client.folders.get(uid)
55
+ client.persist()
56
+ emit(folder, json_output=ctx.obj["json"])
57
+
58
+
59
+ @folders.command(name="create")
60
+ @click.option("--name", required=True, help="Folder name.")
61
+ @click.option("--parent-id", type=int, default=None,
62
+ help="Numeric id of the parent folder (from pm folder list). Default: top level.")
63
+ @click.option("--data", default=None, help="Extra fields as a JSON object.")
64
+ @json_option
65
+ @click.pass_context
66
+ def create(ctx: click.Context, name: str, parent_id: Optional[int], data: Optional[str]) -> None:
67
+ """Create a folder."""
68
+ payload = merge_fields(parse_data(data), {"name": name, "parent_id": parent_id})
69
+ client = PMClient.from_profile(ctx.obj["profile"])
70
+ folder = client.folders.create(payload)
71
+ client.persist()
72
+ emit(folder, json_output=ctx.obj["json"])
73
+
74
+
75
+ @folders.command(name="update")
76
+ @click.argument("uid")
77
+ @click.option("--name", default=None, help="Rename the folder.")
78
+ @click.option("--parent-id", type=int, default=None,
79
+ help="Move under this parent folder (numeric id from pm folder list).")
80
+ @click.option("--data", default=None, help="Extra fields as a JSON object.")
81
+ @json_option
82
+ @click.pass_context
83
+ def update(
84
+ ctx: click.Context,
85
+ uid: str,
86
+ name: Optional[str],
87
+ parent_id: Optional[int],
88
+ data: Optional[str],
89
+ ) -> None:
90
+ """Update a folder."""
91
+ payload = merge_fields(parse_data(data), {"name": name, "parent_id": parent_id})
92
+ if not payload:
93
+ raise click.UsageError("Nothing to update; pass --name, --parent-id, or --data.")
94
+ client = PMClient.from_profile(ctx.obj["profile"])
95
+ folder = client.folders.update(uid, payload)
96
+ client.persist()
97
+ emit(folder, json_output=ctx.obj["json"])
98
+
99
+
100
+ @folders.command(name="delete")
101
+ @click.argument("uid")
102
+ @yes_option
103
+ @json_option
104
+ @click.pass_context
105
+ def delete(ctx: click.Context, uid: str, yes: bool) -> None:
106
+ """Delete a folder."""
107
+ if not yes:
108
+ name = peek_name(lambda: PMClient.from_profile(ctx.obj["profile"]).folders.get(uid), "name")
109
+ confirm_delete("folder", uid, name)
110
+ client = PMClient.from_profile(ctx.obj["profile"])
111
+ client.folders.delete(uid)
112
+ client.persist()
113
+ report_deleted(ctx, "folder", uid)