pushmetrics-cli 0.1.0__tar.gz

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 (140) hide show
  1. pushmetrics_cli-0.1.0/MANIFEST.in +12 -0
  2. pushmetrics_cli-0.1.0/PKG-INFO +221 -0
  3. pushmetrics_cli-0.1.0/README.md +201 -0
  4. pushmetrics_cli-0.1.0/pm/__init__.py +1 -0
  5. pushmetrics_cli-0.1.0/pm/__main__.py +4 -0
  6. pushmetrics_cli-0.1.0/pm/_alias.py +164 -0
  7. pushmetrics_cli-0.1.0/pm/auth/__init__.py +0 -0
  8. pushmetrics_cli-0.1.0/pm/auth/device_flow.py +187 -0
  9. pushmetrics_cli-0.1.0/pm/auth/exchange.py +79 -0
  10. pushmetrics_cli-0.1.0/pm/auth/jwt_cache.py +163 -0
  11. pushmetrics_cli-0.1.0/pm/auth/secret_files.py +58 -0
  12. pushmetrics_cli-0.1.0/pm/auth/session.py +440 -0
  13. pushmetrics_cli-0.1.0/pm/auth/store.py +160 -0
  14. pushmetrics_cli-0.1.0/pm/auth/token.py +289 -0
  15. pushmetrics_cli-0.1.0/pm/cli.py +282 -0
  16. pushmetrics_cli-0.1.0/pm/client/__init__.py +68 -0
  17. pushmetrics_cli-0.1.0/pm/client/access_tokens.py +54 -0
  18. pushmetrics_cli-0.1.0/pm/client/base.py +229 -0
  19. pushmetrics_cli-0.1.0/pm/client/blocks.py +461 -0
  20. pushmetrics_cli-0.1.0/pm/client/databases.py +147 -0
  21. pushmetrics_cli-0.1.0/pm/client/favorites.py +14 -0
  22. pushmetrics_cli-0.1.0/pm/client/folders.py +10 -0
  23. pushmetrics_cli-0.1.0/pm/client/integrations.py +14 -0
  24. pushmetrics_cli-0.1.0/pm/client/organizations.py +20 -0
  25. pushmetrics_cli-0.1.0/pm/client/recipients.py +92 -0
  26. pushmetrics_cli-0.1.0/pm/client/reports.py +148 -0
  27. pushmetrics_cli-0.1.0/pm/client/roles.py +100 -0
  28. pushmetrics_cli-0.1.0/pm/client/runs.py +134 -0
  29. pushmetrics_cli-0.1.0/pm/client/saved_blocks.py +13 -0
  30. pushmetrics_cli-0.1.0/pm/client/tableau.py +108 -0
  31. pushmetrics_cli-0.1.0/pm/client/users.py +71 -0
  32. pushmetrics_cli-0.1.0/pm/client/webhooks.py +43 -0
  33. pushmetrics_cli-0.1.0/pm/client/workspaces.py +34 -0
  34. pushmetrics_cli-0.1.0/pm/commands/__init__.py +0 -0
  35. pushmetrics_cli-0.1.0/pm/commands/_block_factory.py +762 -0
  36. pushmetrics_cli-0.1.0/pm/commands/_helpers.py +178 -0
  37. pushmetrics_cli-0.1.0/pm/commands/auth.py +222 -0
  38. pushmetrics_cli-0.1.0/pm/commands/block.py +26 -0
  39. pushmetrics_cli-0.1.0/pm/commands/chart.py +20 -0
  40. pushmetrics_cli-0.1.0/pm/commands/db.py +291 -0
  41. pushmetrics_cli-0.1.0/pm/commands/email.py +13 -0
  42. pushmetrics_cli-0.1.0/pm/commands/explore.py +20 -0
  43. pushmetrics_cli-0.1.0/pm/commands/favorites.py +77 -0
  44. pushmetrics_cli-0.1.0/pm/commands/folders.py +113 -0
  45. pushmetrics_cli-0.1.0/pm/commands/help.py +643 -0
  46. pushmetrics_cli-0.1.0/pm/commands/integrations.py +95 -0
  47. pushmetrics_cli-0.1.0/pm/commands/metrics_view.py +195 -0
  48. pushmetrics_cli-0.1.0/pm/commands/organizations.py +56 -0
  49. pushmetrics_cli-0.1.0/pm/commands/recipient.py +226 -0
  50. pushmetrics_cli-0.1.0/pm/commands/reports.py +364 -0
  51. pushmetrics_cli-0.1.0/pm/commands/roles.py +102 -0
  52. pushmetrics_cli-0.1.0/pm/commands/runs.py +214 -0
  53. pushmetrics_cli-0.1.0/pm/commands/saved_blocks.py +97 -0
  54. pushmetrics_cli-0.1.0/pm/commands/sftp.py +19 -0
  55. pushmetrics_cli-0.1.0/pm/commands/slack.py +13 -0
  56. pushmetrics_cli-0.1.0/pm/commands/slides.py +216 -0
  57. pushmetrics_cli-0.1.0/pm/commands/sql.py +17 -0
  58. pushmetrics_cli-0.1.0/pm/commands/tableau.py +148 -0
  59. pushmetrics_cli-0.1.0/pm/commands/token.py +107 -0
  60. pushmetrics_cli-0.1.0/pm/commands/users.py +262 -0
  61. pushmetrics_cli-0.1.0/pm/commands/webhooks.py +132 -0
  62. pushmetrics_cli-0.1.0/pm/commands/workspace.py +156 -0
  63. pushmetrics_cli-0.1.0/pm/config.py +192 -0
  64. pushmetrics_cli-0.1.0/pm/errors.py +228 -0
  65. pushmetrics_cli-0.1.0/pm/log.py +63 -0
  66. pushmetrics_cli-0.1.0/pm/models/__init__.py +31 -0
  67. pushmetrics_cli-0.1.0/pm/models/access_tokens.py +25 -0
  68. pushmetrics_cli-0.1.0/pm/models/blocks.py +21 -0
  69. pushmetrics_cli-0.1.0/pm/models/databases.py +15 -0
  70. pushmetrics_cli-0.1.0/pm/models/favorites.py +14 -0
  71. pushmetrics_cli-0.1.0/pm/models/folders.py +14 -0
  72. pushmetrics_cli-0.1.0/pm/models/integrations.py +20 -0
  73. pushmetrics_cli-0.1.0/pm/models/organizations.py +13 -0
  74. pushmetrics_cli-0.1.0/pm/models/recipients.py +41 -0
  75. pushmetrics_cli-0.1.0/pm/models/reports.py +16 -0
  76. pushmetrics_cli-0.1.0/pm/models/roles.py +22 -0
  77. pushmetrics_cli-0.1.0/pm/models/runs.py +56 -0
  78. pushmetrics_cli-0.1.0/pm/models/saved_blocks.py +15 -0
  79. pushmetrics_cli-0.1.0/pm/models/users.py +36 -0
  80. pushmetrics_cli-0.1.0/pm/models/webhooks.py +23 -0
  81. pushmetrics_cli-0.1.0/pm/models/workspaces.py +16 -0
  82. pushmetrics_cli-0.1.0/pm/output.py +92 -0
  83. pushmetrics_cli-0.1.0/pm/types.py +130 -0
  84. pushmetrics_cli-0.1.0/pm/validators.py +499 -0
  85. pushmetrics_cli-0.1.0/pushmetrics_cli.egg-info/PKG-INFO +221 -0
  86. pushmetrics_cli-0.1.0/pushmetrics_cli.egg-info/SOURCES.txt +138 -0
  87. pushmetrics_cli-0.1.0/pushmetrics_cli.egg-info/dependency_links.txt +1 -0
  88. pushmetrics_cli-0.1.0/pushmetrics_cli.egg-info/entry_points.txt +2 -0
  89. pushmetrics_cli-0.1.0/pushmetrics_cli.egg-info/requires.txt +16 -0
  90. pushmetrics_cli-0.1.0/pushmetrics_cli.egg-info/top_level.txt +1 -0
  91. pushmetrics_cli-0.1.0/pyproject.toml +63 -0
  92. pushmetrics_cli-0.1.0/setup.cfg +4 -0
  93. pushmetrics_cli-0.1.0/tests/__init__.py +0 -0
  94. pushmetrics_cli-0.1.0/tests/_runner.py +22 -0
  95. pushmetrics_cli-0.1.0/tests/conftest.py +66 -0
  96. pushmetrics_cli-0.1.0/tests/test_agent_profile.py +72 -0
  97. pushmetrics_cli-0.1.0/tests/test_aliases.py +135 -0
  98. pushmetrics_cli-0.1.0/tests/test_block_factory.py +873 -0
  99. pushmetrics_cli-0.1.0/tests/test_client_access_tokens.py +51 -0
  100. pushmetrics_cli-0.1.0/tests/test_client_blocks.py +743 -0
  101. pushmetrics_cli-0.1.0/tests/test_client_favorites.py +41 -0
  102. pushmetrics_cli-0.1.0/tests/test_client_folders.py +61 -0
  103. pushmetrics_cli-0.1.0/tests/test_client_integrations.py +69 -0
  104. pushmetrics_cli-0.1.0/tests/test_client_organizations.py +45 -0
  105. pushmetrics_cli-0.1.0/tests/test_client_recipients.py +96 -0
  106. pushmetrics_cli-0.1.0/tests/test_client_reports.py +238 -0
  107. pushmetrics_cli-0.1.0/tests/test_client_runs.py +170 -0
  108. pushmetrics_cli-0.1.0/tests/test_client_saved_blocks.py +47 -0
  109. pushmetrics_cli-0.1.0/tests/test_client_tableau.py +118 -0
  110. pushmetrics_cli-0.1.0/tests/test_client_users.py +50 -0
  111. pushmetrics_cli-0.1.0/tests/test_client_webhooks.py +58 -0
  112. pushmetrics_cli-0.1.0/tests/test_credential_safety.py +200 -0
  113. pushmetrics_cli-0.1.0/tests/test_device_flow.py +127 -0
  114. pushmetrics_cli-0.1.0/tests/test_did_you_mean.py +201 -0
  115. pushmetrics_cli-0.1.0/tests/test_domain_groups.py +193 -0
  116. pushmetrics_cli-0.1.0/tests/test_errors.py +165 -0
  117. pushmetrics_cli-0.1.0/tests/test_first_run.py +372 -0
  118. pushmetrics_cli-0.1.0/tests/test_hardening.py +254 -0
  119. pushmetrics_cli-0.1.0/tests/test_help_command.py +433 -0
  120. pushmetrics_cli-0.1.0/tests/test_integration_commands.py +146 -0
  121. pushmetrics_cli-0.1.0/tests/test_jwt_cache.py +337 -0
  122. pushmetrics_cli-0.1.0/tests/test_keyring_namespace.py +245 -0
  123. pushmetrics_cli-0.1.0/tests/test_list_limit.py +154 -0
  124. pushmetrics_cli-0.1.0/tests/test_metrics_view_commands.py +209 -0
  125. pushmetrics_cli-0.1.0/tests/test_no_backend_imports.py +145 -0
  126. pushmetrics_cli-0.1.0/tests/test_packaging.py +106 -0
  127. pushmetrics_cli-0.1.0/tests/test_pm_client.py +60 -0
  128. pushmetrics_cli-0.1.0/tests/test_prod_run_fixes.py +237 -0
  129. pushmetrics_cli-0.1.0/tests/test_profile_pat_only.py +138 -0
  130. pushmetrics_cli-0.1.0/tests/test_quiet_flag.py +61 -0
  131. pushmetrics_cli-0.1.0/tests/test_recipient_commands.py +318 -0
  132. pushmetrics_cli-0.1.0/tests/test_report_preview_commands.py +36 -0
  133. pushmetrics_cli-0.1.0/tests/test_secret_files.py +106 -0
  134. pushmetrics_cli-0.1.0/tests/test_session.py +298 -0
  135. pushmetrics_cli-0.1.0/tests/test_slides_commands.py +244 -0
  136. pushmetrics_cli-0.1.0/tests/test_tableau_commands.py +200 -0
  137. pushmetrics_cli-0.1.0/tests/test_token_auth.py +627 -0
  138. pushmetrics_cli-0.1.0/tests/test_token_commands.py +192 -0
  139. pushmetrics_cli-0.1.0/tests/test_types.py +159 -0
  140. pushmetrics_cli-0.1.0/tests/test_validators.py +815 -0
@@ -0,0 +1,12 @@
1
+ # What the sdist carries beyond the `pm` package and the metadata files
2
+ # setuptools adds on its own. The wheel is governed by packages.find in
3
+ # pyproject.toml and never includes any of this.
4
+ #
5
+ # tests/ ships complete (conftest, package marker, helpers) so the sdist is
6
+ # testable as downloaded; setuptools' default would copy only tests/test_*.py.
7
+ # tests/repo_only/ needs the monorepo (it reads backend files by path and
8
+ # hard-fails without them), so it is repo-only: see its __init__.py.
9
+ recursive-include tests *.py
10
+ prune tests/repo_only
11
+ # Developer-only live-backend exercisers. Never part of the distribution.
12
+ prune scripts
@@ -0,0 +1,221 @@
1
+ Metadata-Version: 2.4
2
+ Name: pushmetrics-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line interface for PushMetrics
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: requests>=2.31
9
+ Requires-Dist: keyring>=24.0
10
+ Requires-Dist: pydantic>=2.0
11
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0; extra == "dev"
14
+ Requires-Dist: responses>=0.23; extra == "dev"
15
+ Requires-Dist: ruff>=0.6; extra == "dev"
16
+ Requires-Dist: build>=1.0; extra == "dev"
17
+ Requires-Dist: setuptools>=68; extra == "dev"
18
+ Requires-Dist: wheel; extra == "dev"
19
+ Requires-Dist: pyyaml>=6; extra == "dev"
20
+
21
+ # PushMetrics CLI (`pm`)
22
+
23
+ `pm` is the command-line client for [PushMetrics](https://pushmetrics.io). It talks to the same REST API the web app uses, with the same permissions: you see the reports, databases and users you have access to, in one workspace, nothing more.
24
+
25
+ Use it to run and debug reports, keep report definitions in git, feed CI pipelines, or give an AI coding agent a scriptable handle on your workspace. Every command has a `--json` mode.
26
+
27
+ The complete user guide is on the website: [PushMetrics CLI](https://pushmetrics.io/docs/admin/cli/).
28
+
29
+ ## Install
30
+
31
+ > **PLACEHOLDER (D1).** The install command depends on the distribution channel and is filled in at release time.
32
+
33
+ ```bash
34
+ # PLACEHOLDER (D1): install command goes here
35
+ pm --version
36
+ ```
37
+
38
+ Requires Python 3.9 or newer. Runs on macOS, Linux and Windows.
39
+
40
+ ## Quick start
41
+
42
+ 1. In the PushMetrics app, open **Settings → Access Tokens** (`/a/tokens`), click **New token**, and copy the `pmpat_...` value. It is shown once, and it is bound to the workspace that was active when you created it.
43
+ 2. Log in with the token. `pm` reads it from a hidden prompt, or from stdin when piped, and stores it in your OS credential store:
44
+
45
+ ```bash
46
+ pm login --token
47
+ pm whoami
48
+ ```
49
+
50
+ 3. Use it:
51
+
52
+ ```bash
53
+ pm report list
54
+ pm report get <report-uid>
55
+ pm report run <report-uid> --send-to-me
56
+ pm run list --last-days 2 --status failed
57
+ pm run logs <run-uid> --level ERROR
58
+ pm report list --json
59
+ ```
60
+
61
+ In CI, skip `pm login` and set `PM_API_TOKEN` instead.
62
+
63
+ > **PLACEHOLDER (D2).** Browser login (`pm login` without `--token`, Auth0 device flow) is documented here only if it ships in the first release.
64
+
65
+ ## Commands
66
+
67
+ `pm --help` lists every command. `pm <group> --help` lists a group's actions, `pm <group> <action> --help` the options, and `pm help <group>` shows a compact page with examples.
68
+
69
+ ```
70
+ Account:
71
+ login Log in with a personal access token.
72
+ logout Log out and forget the stored token or session.
73
+ whoami Show who you are logged in as, and the workspace.
74
+ token Personal access tokens for API / CLI authentication.
75
+ config Profiles: which PushMetrics server pm talks to.
76
+
77
+ Commands:
78
+ block Blocks of any type.
79
+ chart Charts (Plotly blocks).
80
+ database Databases, their schemas and tables.
81
+ email Email blocks.
82
+ explore Explore blocks (saved data views).
83
+ favorite Your favorite reports and other objects.
84
+ folder Folders that organize reports.
85
+ help Show compact help for a group, with examples.
86
+ integration Connected services (Slack, Tableau, Google Drive, ...).
87
+ metrics-view Metrics views (the semantic layer).
88
+ organization Your organization (the owner of workspaces).
89
+ recipient Recipients: who reports are delivered to.
90
+ report Reports and their runs.
91
+ role Roles and groups in the current workspace.
92
+ run Report runs: status, results and logs.
93
+ saved-block Saved blocks (reusable block templates).
94
+ sftp SFTP upload blocks.
95
+ slack Slack message blocks.
96
+ slides Google Slides blocks.
97
+ sql SQL query blocks.
98
+ tableau Tableau view blocks.
99
+ user Users in the current workspace.
100
+ webhook Webhooks that trigger report runs.
101
+ workspace Workspaces you belong to.
102
+ ```
103
+
104
+ The ones most people need:
105
+
106
+ | Command | What it does |
107
+ |---|---|
108
+ | `pm whoami` | Your user and the token's workspace. |
109
+ | `pm report list` | Reports in the workspace. `--name` filters by name, `--limit` caps the rows. |
110
+ | `pm report get <uid>` | One report. Full record with `--json`. |
111
+ | `pm report yaml <uid>` | The report's full YAML configuration, to stdout. |
112
+ | `pm report apply <file> --uid <uid>` | Write a YAML file back to a report. `--dry-run` shows the diff without saving. |
113
+ | `pm report run <uid>` | Start a run and print its uid. `--send-to-me` sends the email blocks to you and skips every other destination; `--email <address>` previews as a saved recipient. |
114
+ | `pm run list` | Recent runs, newest first. `--report <uid>`, `--last-days N`, `--status failed`, `--limit N`. |
115
+ | `pm run get <run-uid>` | A run's status and timing. |
116
+ | `pm run logs <run-uid>` | A run's log lines. `--level ERROR` keeps only errors. |
117
+ | `pm run results <run-uid>` | What each block of the run produced. |
118
+ | `pm token list` / `create <name>` / `revoke <id>` | Manage your personal access tokens. |
119
+ | `pm config list` | Profiles and the environment overrides in effect. |
120
+
121
+ `--json` works before or after the action (`pm --json report list` and `pm report list --json`). Data goes to stdout; messages and errors go to stderr. `pm` exits with `0` on success, `1` when a command fails and `2` when the command line is wrong.
122
+
123
+ ### Global options
124
+
125
+ | Option | Meaning |
126
+ |---|---|
127
+ | `--json` | Print JSON instead of tables. |
128
+ | `--quiet` | Suppress informational messages on stderr. Errors still print. |
129
+ | `-v`, `--verbose` | Log each request, its status and timing to stderr; response bodies on errors only. Tokens are never logged. |
130
+ | `--profile <name>` | Use a profile other than `default`. |
131
+ | `--api-url <url>` | Talk to a different PushMetrics address for one command. |
132
+ | `--version` | Print the version and exit. |
133
+
134
+ ### Environment variables
135
+
136
+ | Variable | Purpose |
137
+ |---|---|
138
+ | `PM_API_TOKEN` | A personal access token. Wins over a token stored by `pm login --token`. For CI and agents. |
139
+ | `PM_PROFILE` | Profile to use when no `--profile` is given. |
140
+ | `PM_API_URL` | Talk to this address instead of the profile's. |
141
+ | `PM_CONFIG_HOME` | Where configuration and fallback credential files live. Default: `~/.pm`. |
142
+
143
+ ## Several workspaces
144
+
145
+ A token works in one workspace. To work in a second one, create a token there and give it its own profile:
146
+
147
+ ```bash
148
+ pm config set acme api_url https://pushmetrics.io
149
+ pm --profile acme login --token
150
+ pm --profile acme report list
151
+ ```
152
+
153
+ `pm workspace switch` is for browser login only; with a token, the token decides the workspace.
154
+
155
+ ## Where credentials live
156
+
157
+ `pm login --token` stores the token in the OS credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service) under the service name `pushmetrics-cli-pat`, one entry per profile. Without a usable credential store it falls back to `~/.pm/tokens.json`, mode `0600`.
158
+
159
+ `pm` also caches the short-lived access token it exchanges from your personal access token, under the service name `pushmetrics-cli-jwt` or in `~/.pm/jwt_cache.json` with mode `0600`.
160
+
161
+ `pm logout` removes both for the active profile. It does not revoke the token on the server; use `pm token revoke` or the Access Tokens page for that.
162
+
163
+ `pm` never takes a token on the command line and never prints one, in `-v` output or in errors. It only sends tokens over `https://`, except to `localhost`.
164
+
165
+ ## Troubleshooting
166
+
167
+ **`Not logged in. Run pm login --token with a personal access token, or set PM_API_TOKEN.`** Nothing stored for the active profile and `PM_API_TOKEN` unset. Tokens are stored per profile: check `pm config list`.
168
+
169
+ **`That does not look like a personal access token…`** What was pasted, piped or set in `PM_API_TOKEN` is not a `pmpat_...` value. Copy it again exactly.
170
+
171
+ **`Personal access token rejected (invalid, expired, or revoked).`** Create a new token and log in again.
172
+
173
+ **`Could not reach https://... (ConnectionError)…`** Network, or the wrong address: `pm config list` shows it.
174
+
175
+ **`Unknown profile: 'acme'…`** or **`Profile 'acme' has no api_url…`** Each message ends with the `pm config set` command that fixes it; see also [Several workspaces](#several-workspaces).
176
+
177
+ **`API ... failed: 403` / `404` with a `Hint:` line.** The hint names the next step: permissions in the token's workspace, or an id that is not visible there.
178
+
179
+ **Anything else.** Re-run with `pm -v ...` and look at the response status and body.
180
+
181
+ The [website guide](https://pushmetrics.io/docs/admin/cli/) lists every error with its fix.
182
+
183
+ ## Contributing
184
+
185
+ The CLI lives in the `cli/` directory of the PushMetrics monorepo and must not import anything from the backend packages (`superset/`, `prefect/`); a test enforces this. To work on it:
186
+
187
+ ```bash
188
+ cd cli
189
+ python -m venv .venv && source .venv/bin/activate
190
+ pip install -e '.[dev]' # contributors only; users install the released package
191
+ pytest tests/ -v
192
+ ruff check .
193
+ ```
194
+
195
+ Tests mock HTTP with the `responses` library and never reach a real backend. `tests/repo_only/` holds tests that read files outside `cli/`, such as `test_docs_commands_exist.py`, which checks every `pm` command written in this README and in the website guide against the real command tree; they run from a checkout and are not shipped in the sdist. CI runs the suite on every pull request that touches `cli/`.
196
+
197
+ When you run a real `pm` process during development, set `PM_CONFIG_HOME` to a scratch directory **and** `PYTHON_KEYRING_BACKEND=keyring.backends.fail.Keyring`. `PM_CONFIG_HOME` alone does not isolate credentials: the OS keyring is keyed by profile name, so a scratch run would read, and `pm logout` would delete, your real stored token.
198
+
199
+ **Adding a resource.** For anything with the standard CRUD shape (`/<resource>/` list, `/<resource>/<uid>` single, plus `POST`/`PUT`/`DELETE`):
200
+
201
+ 1. **Model** in `pm/models/<resource>.py`: a Pydantic `BaseModel` with `extra="allow"`; re-export from `pm/models/__init__.py`.
202
+ 2. **Client** in `pm/client/<resource>.py`: subclass `CrudClient[Model]`, set `base_path` and `model`. Register it on the `PMClient` facade in `pm/client/__init__.py`.
203
+ 3. **Commands** in `pm/commands/<resource>.py`: a `click.group(cls=AliasedGroup, aliases={"ls": "list"})` with `list/get/create/update/delete`; use `parse_data` and `merge_fields` from `pm/commands/_helpers.py` for the `--data '<json>'` pattern. Skip verbs the backend does not support.
204
+ 4. **Wire it** in `pm/cli.py` with `cli.add_command(...)`.
205
+ 5. **Tests** in `tests/test_client_<resource>.py`, asserting on model attributes.
206
+ 6. **Docs**: add the command to the website guide (`website/content/posts/docs/admin/2026-10-12-cli.md`); the docs test fails if a documented command does not exist.
207
+
208
+ Endpoint paths to mirror live in `frontend/src/shared/services/*.ts`; the frontend prefixes everything with `/api/v1`.
209
+
210
+ The `PMClient` facade is reusable from Python without the command layer:
211
+
212
+ ```python
213
+ from pm.client import PMClient
214
+ from pm.config import get_profile
215
+
216
+ client = PMClient.from_profile(get_profile("default"))
217
+ for report in client.reports.list():
218
+ print(report.uid, report.name)
219
+ ```
220
+
221
+ Authentication resolution order inside `PMSession.from_profile`: `PM_API_JWT` (a delegation JWT the PushMetrics AI agent sets, used as-is) > personal access token (`PM_API_TOKEN` or stored, exchanged at `/api/v1/security/exchange` for a short-lived workspace-scoped JWT) > Auth0 session cookies from a browser login. Bearer JWTs carry a `workspace_id` claim that the backend installs as the request's workspace, so a pure Bearer client has full workspace context; cookies are the interactive path only.
@@ -0,0 +1,201 @@
1
+ # PushMetrics CLI (`pm`)
2
+
3
+ `pm` is the command-line client for [PushMetrics](https://pushmetrics.io). It talks to the same REST API the web app uses, with the same permissions: you see the reports, databases and users you have access to, in one workspace, nothing more.
4
+
5
+ Use it to run and debug reports, keep report definitions in git, feed CI pipelines, or give an AI coding agent a scriptable handle on your workspace. Every command has a `--json` mode.
6
+
7
+ The complete user guide is on the website: [PushMetrics CLI](https://pushmetrics.io/docs/admin/cli/).
8
+
9
+ ## Install
10
+
11
+ > **PLACEHOLDER (D1).** The install command depends on the distribution channel and is filled in at release time.
12
+
13
+ ```bash
14
+ # PLACEHOLDER (D1): install command goes here
15
+ pm --version
16
+ ```
17
+
18
+ Requires Python 3.9 or newer. Runs on macOS, Linux and Windows.
19
+
20
+ ## Quick start
21
+
22
+ 1. In the PushMetrics app, open **Settings → Access Tokens** (`/a/tokens`), click **New token**, and copy the `pmpat_...` value. It is shown once, and it is bound to the workspace that was active when you created it.
23
+ 2. Log in with the token. `pm` reads it from a hidden prompt, or from stdin when piped, and stores it in your OS credential store:
24
+
25
+ ```bash
26
+ pm login --token
27
+ pm whoami
28
+ ```
29
+
30
+ 3. Use it:
31
+
32
+ ```bash
33
+ pm report list
34
+ pm report get <report-uid>
35
+ pm report run <report-uid> --send-to-me
36
+ pm run list --last-days 2 --status failed
37
+ pm run logs <run-uid> --level ERROR
38
+ pm report list --json
39
+ ```
40
+
41
+ In CI, skip `pm login` and set `PM_API_TOKEN` instead.
42
+
43
+ > **PLACEHOLDER (D2).** Browser login (`pm login` without `--token`, Auth0 device flow) is documented here only if it ships in the first release.
44
+
45
+ ## Commands
46
+
47
+ `pm --help` lists every command. `pm <group> --help` lists a group's actions, `pm <group> <action> --help` the options, and `pm help <group>` shows a compact page with examples.
48
+
49
+ ```
50
+ Account:
51
+ login Log in with a personal access token.
52
+ logout Log out and forget the stored token or session.
53
+ whoami Show who you are logged in as, and the workspace.
54
+ token Personal access tokens for API / CLI authentication.
55
+ config Profiles: which PushMetrics server pm talks to.
56
+
57
+ Commands:
58
+ block Blocks of any type.
59
+ chart Charts (Plotly blocks).
60
+ database Databases, their schemas and tables.
61
+ email Email blocks.
62
+ explore Explore blocks (saved data views).
63
+ favorite Your favorite reports and other objects.
64
+ folder Folders that organize reports.
65
+ help Show compact help for a group, with examples.
66
+ integration Connected services (Slack, Tableau, Google Drive, ...).
67
+ metrics-view Metrics views (the semantic layer).
68
+ organization Your organization (the owner of workspaces).
69
+ recipient Recipients: who reports are delivered to.
70
+ report Reports and their runs.
71
+ role Roles and groups in the current workspace.
72
+ run Report runs: status, results and logs.
73
+ saved-block Saved blocks (reusable block templates).
74
+ sftp SFTP upload blocks.
75
+ slack Slack message blocks.
76
+ slides Google Slides blocks.
77
+ sql SQL query blocks.
78
+ tableau Tableau view blocks.
79
+ user Users in the current workspace.
80
+ webhook Webhooks that trigger report runs.
81
+ workspace Workspaces you belong to.
82
+ ```
83
+
84
+ The ones most people need:
85
+
86
+ | Command | What it does |
87
+ |---|---|
88
+ | `pm whoami` | Your user and the token's workspace. |
89
+ | `pm report list` | Reports in the workspace. `--name` filters by name, `--limit` caps the rows. |
90
+ | `pm report get <uid>` | One report. Full record with `--json`. |
91
+ | `pm report yaml <uid>` | The report's full YAML configuration, to stdout. |
92
+ | `pm report apply <file> --uid <uid>` | Write a YAML file back to a report. `--dry-run` shows the diff without saving. |
93
+ | `pm report run <uid>` | Start a run and print its uid. `--send-to-me` sends the email blocks to you and skips every other destination; `--email <address>` previews as a saved recipient. |
94
+ | `pm run list` | Recent runs, newest first. `--report <uid>`, `--last-days N`, `--status failed`, `--limit N`. |
95
+ | `pm run get <run-uid>` | A run's status and timing. |
96
+ | `pm run logs <run-uid>` | A run's log lines. `--level ERROR` keeps only errors. |
97
+ | `pm run results <run-uid>` | What each block of the run produced. |
98
+ | `pm token list` / `create <name>` / `revoke <id>` | Manage your personal access tokens. |
99
+ | `pm config list` | Profiles and the environment overrides in effect. |
100
+
101
+ `--json` works before or after the action (`pm --json report list` and `pm report list --json`). Data goes to stdout; messages and errors go to stderr. `pm` exits with `0` on success, `1` when a command fails and `2` when the command line is wrong.
102
+
103
+ ### Global options
104
+
105
+ | Option | Meaning |
106
+ |---|---|
107
+ | `--json` | Print JSON instead of tables. |
108
+ | `--quiet` | Suppress informational messages on stderr. Errors still print. |
109
+ | `-v`, `--verbose` | Log each request, its status and timing to stderr; response bodies on errors only. Tokens are never logged. |
110
+ | `--profile <name>` | Use a profile other than `default`. |
111
+ | `--api-url <url>` | Talk to a different PushMetrics address for one command. |
112
+ | `--version` | Print the version and exit. |
113
+
114
+ ### Environment variables
115
+
116
+ | Variable | Purpose |
117
+ |---|---|
118
+ | `PM_API_TOKEN` | A personal access token. Wins over a token stored by `pm login --token`. For CI and agents. |
119
+ | `PM_PROFILE` | Profile to use when no `--profile` is given. |
120
+ | `PM_API_URL` | Talk to this address instead of the profile's. |
121
+ | `PM_CONFIG_HOME` | Where configuration and fallback credential files live. Default: `~/.pm`. |
122
+
123
+ ## Several workspaces
124
+
125
+ A token works in one workspace. To work in a second one, create a token there and give it its own profile:
126
+
127
+ ```bash
128
+ pm config set acme api_url https://pushmetrics.io
129
+ pm --profile acme login --token
130
+ pm --profile acme report list
131
+ ```
132
+
133
+ `pm workspace switch` is for browser login only; with a token, the token decides the workspace.
134
+
135
+ ## Where credentials live
136
+
137
+ `pm login --token` stores the token in the OS credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service) under the service name `pushmetrics-cli-pat`, one entry per profile. Without a usable credential store it falls back to `~/.pm/tokens.json`, mode `0600`.
138
+
139
+ `pm` also caches the short-lived access token it exchanges from your personal access token, under the service name `pushmetrics-cli-jwt` or in `~/.pm/jwt_cache.json` with mode `0600`.
140
+
141
+ `pm logout` removes both for the active profile. It does not revoke the token on the server; use `pm token revoke` or the Access Tokens page for that.
142
+
143
+ `pm` never takes a token on the command line and never prints one, in `-v` output or in errors. It only sends tokens over `https://`, except to `localhost`.
144
+
145
+ ## Troubleshooting
146
+
147
+ **`Not logged in. Run pm login --token with a personal access token, or set PM_API_TOKEN.`** Nothing stored for the active profile and `PM_API_TOKEN` unset. Tokens are stored per profile: check `pm config list`.
148
+
149
+ **`That does not look like a personal access token…`** What was pasted, piped or set in `PM_API_TOKEN` is not a `pmpat_...` value. Copy it again exactly.
150
+
151
+ **`Personal access token rejected (invalid, expired, or revoked).`** Create a new token and log in again.
152
+
153
+ **`Could not reach https://... (ConnectionError)…`** Network, or the wrong address: `pm config list` shows it.
154
+
155
+ **`Unknown profile: 'acme'…`** or **`Profile 'acme' has no api_url…`** Each message ends with the `pm config set` command that fixes it; see also [Several workspaces](#several-workspaces).
156
+
157
+ **`API ... failed: 403` / `404` with a `Hint:` line.** The hint names the next step: permissions in the token's workspace, or an id that is not visible there.
158
+
159
+ **Anything else.** Re-run with `pm -v ...` and look at the response status and body.
160
+
161
+ The [website guide](https://pushmetrics.io/docs/admin/cli/) lists every error with its fix.
162
+
163
+ ## Contributing
164
+
165
+ The CLI lives in the `cli/` directory of the PushMetrics monorepo and must not import anything from the backend packages (`superset/`, `prefect/`); a test enforces this. To work on it:
166
+
167
+ ```bash
168
+ cd cli
169
+ python -m venv .venv && source .venv/bin/activate
170
+ pip install -e '.[dev]' # contributors only; users install the released package
171
+ pytest tests/ -v
172
+ ruff check .
173
+ ```
174
+
175
+ Tests mock HTTP with the `responses` library and never reach a real backend. `tests/repo_only/` holds tests that read files outside `cli/`, such as `test_docs_commands_exist.py`, which checks every `pm` command written in this README and in the website guide against the real command tree; they run from a checkout and are not shipped in the sdist. CI runs the suite on every pull request that touches `cli/`.
176
+
177
+ When you run a real `pm` process during development, set `PM_CONFIG_HOME` to a scratch directory **and** `PYTHON_KEYRING_BACKEND=keyring.backends.fail.Keyring`. `PM_CONFIG_HOME` alone does not isolate credentials: the OS keyring is keyed by profile name, so a scratch run would read, and `pm logout` would delete, your real stored token.
178
+
179
+ **Adding a resource.** For anything with the standard CRUD shape (`/<resource>/` list, `/<resource>/<uid>` single, plus `POST`/`PUT`/`DELETE`):
180
+
181
+ 1. **Model** in `pm/models/<resource>.py`: a Pydantic `BaseModel` with `extra="allow"`; re-export from `pm/models/__init__.py`.
182
+ 2. **Client** in `pm/client/<resource>.py`: subclass `CrudClient[Model]`, set `base_path` and `model`. Register it on the `PMClient` facade in `pm/client/__init__.py`.
183
+ 3. **Commands** in `pm/commands/<resource>.py`: a `click.group(cls=AliasedGroup, aliases={"ls": "list"})` with `list/get/create/update/delete`; use `parse_data` and `merge_fields` from `pm/commands/_helpers.py` for the `--data '<json>'` pattern. Skip verbs the backend does not support.
184
+ 4. **Wire it** in `pm/cli.py` with `cli.add_command(...)`.
185
+ 5. **Tests** in `tests/test_client_<resource>.py`, asserting on model attributes.
186
+ 6. **Docs**: add the command to the website guide (`website/content/posts/docs/admin/2026-10-12-cli.md`); the docs test fails if a documented command does not exist.
187
+
188
+ Endpoint paths to mirror live in `frontend/src/shared/services/*.ts`; the frontend prefixes everything with `/api/v1`.
189
+
190
+ The `PMClient` facade is reusable from Python without the command layer:
191
+
192
+ ```python
193
+ from pm.client import PMClient
194
+ from pm.config import get_profile
195
+
196
+ client = PMClient.from_profile(get_profile("default"))
197
+ for report in client.reports.list():
198
+ print(report.uid, report.name)
199
+ ```
200
+
201
+ Authentication resolution order inside `PMSession.from_profile`: `PM_API_JWT` (a delegation JWT the PushMetrics AI agent sets, used as-is) > personal access token (`PM_API_TOKEN` or stored, exchanged at `/api/v1/security/exchange` for a short-lived workspace-scoped JWT) > Auth0 session cookies from a browser login. Bearer JWTs carry a `workspace_id` claim that the backend installs as the request's workspace, so a pure Bearer client has full workspace context; cookies are the interactive path only.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from pm.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -0,0 +1,164 @@
1
+ """Click group with command-name aliases + fuzzy "Did you mean?".
2
+
3
+ pm-tool introduces canonical verbs (`list`, `create`, `delete`, `get`) that
4
+ replace older non-REST names (`ls`, `add`, `remove`). The legacy names stay
5
+ as soft aliases — both forms work — so existing CLI users aren't broken.
6
+ Same pattern for the plural→singular group rename (`reports`→`report`).
7
+
8
+ When neither a direct match nor an alias resolves, the group attempts a
9
+ fuzzy suggestion via difflib so typos like ``pm blcok`` get a hint
10
+ toward ``block`` instead of a bare "No such command" error.
11
+
12
+ Usage:
13
+
14
+ from pm._alias import AliasedGroup
15
+
16
+ @click.group(name="user", cls=AliasedGroup, aliases={"add": "create"})
17
+ def user():
18
+ ...
19
+
20
+ @user.command(name="create")
21
+ def create_user(...):
22
+ ...
23
+
24
+ Now ``pm user create`` and ``pm user add`` both resolve to ``create_user``.
25
+ Aliases are NOT listed in ``--help`` (to keep the canonical surface clean);
26
+ ``pm help`` (pm-foundation) is the agent-facing help system and will
27
+ mention compatibility aliases explicitly when relevant.
28
+
29
+ Groups that don't need legacy aliases still benefit from the fuzzy
30
+ suggestion behavior — pass ``aliases={}`` (the default) and the
31
+ suggestion path is the only added behavior over a plain ``click.Group``.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import difflib
36
+ from typing import Optional
37
+
38
+ import click
39
+
40
+
41
+ # Fuzzy threshold. Codex review round 2 (2026-06-09) showed that 0.7
42
+ # is too tight for 3-char verbs — common transpositions/substitutions
43
+ # like ``gte→get`` and ``rnu→run`` score about 0.67. 0.6 picks those
44
+ # up while still rejecting genuinely-different tokens (``xyz`` vs
45
+ # any verb scores ~0.0). The MIN_LEN floor was also dropped: short
46
+ # tokens like ``ge → get`` (0.8) are HELPFUL suggestions, not noise.
47
+ _FUZZY_CUTOFF = 0.6
48
+
49
+
50
+ class AliasedGroup(click.Group):
51
+ """Click group that resolves a configurable map of legacy command
52
+ names to their canonical equivalents.
53
+
54
+ Aliases are checked AFTER direct command lookup, so a real command
55
+ with the same name as an alias would always win (defensive — no
56
+ silent shadowing). Unknown commands trigger a fuzzy suggestion
57
+ when one is available; otherwise Click's standard error renders.
58
+
59
+ Subgroups created via ``@aliased_group_instance.group()`` inherit
60
+ this class via the ``group()`` override below — without it, nested
61
+ ``@cli.group()`` declarations default back to ``click.Group`` and
62
+ miss the typo behavior. Codex review round 2 catch on
63
+ ``cli/pm/cli.py:85`` (``@cli.group(name="config")``).
64
+ """
65
+
66
+ def __init__(self, *args, aliases: Optional[dict[str, str]] = None, **kwargs):
67
+ super().__init__(*args, **kwargs)
68
+ self._aliases: dict[str, str] = aliases or {}
69
+
70
+ def group(self, *args, **kwargs):
71
+ """Default subgroup class to ``type(self)`` so nested
72
+ ``@aliased_group.group()`` declarations inherit the runtime
73
+ class — transitively, for subclasses of ``AliasedGroup``. Codex
74
+ round 3 catch: hard-coding ``AliasedGroup`` here lost subclass
75
+ behavior on subgroups (a hypothetical ``FancyAliasedGroup``
76
+ would have nested groups fall back to plain ``AliasedGroup``).
77
+ Callers can still override with ``cls=`` if they need a
78
+ different group class for a specific subgroup.
79
+ """
80
+ kwargs.setdefault("cls", type(self))
81
+ return super().group(*args, **kwargs)
82
+
83
+ def get_command(self, ctx: click.Context, cmd_name: str):
84
+ rv = super().get_command(ctx, cmd_name)
85
+ if rv is not None:
86
+ return rv
87
+ canonical = self._aliases.get(cmd_name)
88
+ if canonical is not None:
89
+ return super().get_command(ctx, canonical)
90
+ return None
91
+
92
+ def resolve_command(self, ctx: click.Context, args):
93
+ """Run Click's normal resolution; on a missing-command
94
+ ``UsageError``, attempt a fuzzy suggestion.
95
+
96
+ Codex round 2 catch: catching every ``UsageError`` over-fires
97
+ on option errors like ``pm --block`` ("No such option"), which
98
+ would mis-render as "Did you mean 'block'?". Filter by the
99
+ ``No such command`` prefix Click uses for unknown-command
100
+ errors so option errors fall through to the standard renderer.
101
+ """
102
+ try:
103
+ return super().resolve_command(ctx, args)
104
+ except click.UsageError as exc:
105
+ if not _is_no_such_command_error(exc):
106
+ raise
107
+ attempted = args[0] if args else ""
108
+ suggestion = self._fuzzy_suggest(ctx, attempted)
109
+ if suggestion is None:
110
+ raise
111
+ raise click.UsageError(
112
+ f"No such command {attempted!r}. "
113
+ f"Did you mean {suggestion!r}?",
114
+ ctx=ctx,
115
+ ) from exc
116
+
117
+ def _fuzzy_suggest(self, ctx: click.Context, attempted: str) -> Optional[str]:
118
+ """Return the best fuzzy match for ``attempted`` against this
119
+ group's registered commands, or None when no candidate scores
120
+ above the cutoff.
121
+
122
+ Codex round 2 catch: aliases are included in the candidate
123
+ SEARCH set so typos near a legacy alias still resolve, but
124
+ the returned suggestion is the canonical command — never the
125
+ alias. This matches the project's "aliases work but aren't
126
+ advertised in --help" stance.
127
+
128
+ Codex round 3 catch: defensive check that the resolved
129
+ canonical actually exists in ``list_commands``. A malformed
130
+ alias map (``aliases={"ls": "nonexistent"}``) would otherwise
131
+ surface ``"nonexistent"`` as the suggestion — a bad UX on
132
+ top of a config error.
133
+ """
134
+ if not attempted:
135
+ return None
136
+ registered = set(self.list_commands(ctx))
137
+ candidates = list(registered) + list(self._aliases.keys())
138
+ matches = difflib.get_close_matches(
139
+ attempted, candidates, n=1, cutoff=_FUZZY_CUTOFF,
140
+ )
141
+ if not matches:
142
+ return None
143
+ match = matches[0]
144
+ # Resolve alias → canonical. If ``match`` is a real command,
145
+ # ``self._aliases.get(match)`` returns None and we keep the
146
+ # original — the get() default lookup pattern.
147
+ resolved = self._aliases.get(match, match)
148
+ # Defensive: drop dangling-alias suggestions rather than
149
+ # render a misleading "Did you mean ..." for a command that
150
+ # doesn't exist.
151
+ if resolved not in registered:
152
+ return None
153
+ return resolved
154
+
155
+
156
+ def _is_no_such_command_error(exc: click.UsageError) -> bool:
157
+ """Click renders unknown-command errors with a message starting
158
+ ``"No such command"``. Option errors start ``"No such option"`` and
159
+ other UsageErrors (missing args, invalid values) have unrelated
160
+ prefixes. Filter strictly so the fuzzy suggestion only attaches
161
+ where it makes sense.
162
+ """
163
+ message = exc.message if exc.message else ""
164
+ return message.lower().startswith("no such command")
File without changes