pushmetrics-cli 0.1.0__tar.gz → 0.2.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.
- {pushmetrics_cli-0.1.0/pushmetrics_cli.egg-info → pushmetrics_cli-0.2.0}/PKG-INFO +12 -8
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/README.md +11 -7
- pushmetrics_cli-0.2.0/pm/__init__.py +1 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/session.py +6 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/token.py +7 -1
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/cli.py +36 -3
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/__init__.py +4 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/blocks.py +1 -0
- pushmetrics_cli-0.2.0/pm/client/deliverability.py +37 -0
- pushmetrics_cli-0.2.0/pm/client/schedules.py +49 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/_helpers.py +17 -1
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/auth.py +10 -9
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/db.py +7 -7
- pushmetrics_cli-0.2.0/pm/commands/deliverability.py +182 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/help.py +30 -2
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/reports.py +6 -5
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/roles.py +3 -3
- pushmetrics_cli-0.2.0/pm/commands/schedule.py +313 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/users.py +2 -2
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/config.py +62 -2
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/errors.py +25 -1
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/output.py +28 -2
- pushmetrics_cli-0.2.0/pm/user_agent.py +67 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0/pushmetrics_cli.egg-info}/PKG-INFO +12 -8
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/SOURCES.txt +15 -0
- pushmetrics_cli-0.2.0/tests/test_api_line_keeps_lines.py +75 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_blocks.py +45 -0
- pushmetrics_cli-0.2.0/tests/test_config_atomic.py +127 -0
- pushmetrics_cli-0.2.0/tests/test_deliverability_commands.py +225 -0
- pushmetrics_cli-0.2.0/tests/test_e2_no_domain.py +23 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_first_run.py +193 -1
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_help_command.py +7 -7
- pushmetrics_cli-0.2.0/tests/test_help_encoding.py +50 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_keyring_namespace.py +0 -1
- pushmetrics_cli-0.2.0/tests/test_multiline_control_chars.py +173 -0
- pushmetrics_cli-0.2.0/tests/test_output_control_chars.py +47 -0
- pushmetrics_cli-0.2.0/tests/test_schedule_commands.py +432 -0
- pushmetrics_cli-0.2.0/tests/test_server_names_sanitised.py +75 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_session.py +29 -0
- pushmetrics_cli-0.2.0/tests/test_user_agent.py +55 -0
- pushmetrics_cli-0.1.0/pm/__init__.py +0 -1
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/MANIFEST.in +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/__main__.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/_alias.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/__init__.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/device_flow.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/exchange.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/jwt_cache.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/secret_files.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/store.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/access_tokens.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/base.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/databases.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/favorites.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/folders.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/integrations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/organizations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/recipients.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/reports.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/roles.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/runs.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/saved_blocks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/tableau.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/users.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/webhooks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/workspaces.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/__init__.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/_block_factory.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/block.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/chart.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/email.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/explore.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/favorites.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/folders.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/integrations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/metrics_view.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/organizations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/recipient.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/runs.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/saved_blocks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/sftp.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/slack.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/slides.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/sql.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/tableau.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/token.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/webhooks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/workspace.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/log.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/__init__.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/access_tokens.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/blocks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/databases.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/favorites.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/folders.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/integrations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/organizations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/recipients.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/reports.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/roles.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/runs.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/saved_blocks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/users.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/webhooks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/workspaces.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/types.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/validators.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/dependency_links.txt +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/entry_points.txt +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/requires.txt +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/top_level.txt +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pyproject.toml +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/setup.cfg +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/__init__.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/_runner.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/conftest.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_agent_profile.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_aliases.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_block_factory.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_access_tokens.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_favorites.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_folders.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_integrations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_organizations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_recipients.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_reports.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_runs.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_saved_blocks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_tableau.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_users.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_webhooks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_credential_safety.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_device_flow.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_did_you_mean.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_domain_groups.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_errors.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_hardening.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_integration_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_jwt_cache.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_list_limit.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_metrics_view_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_no_backend_imports.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_packaging.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_pm_client.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_prod_run_fixes.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_profile_pat_only.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_quiet_flag.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_recipient_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_report_preview_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_secret_files.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_slides_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_tableau_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_token_auth.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_token_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_types.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_validators.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pushmetrics-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Command-line interface for PushMetrics
|
|
5
5
|
Requires-Python: >=3.9
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -28,18 +28,20 @@ The complete user guide is on the website: [PushMetrics CLI](https://pushmetrics
|
|
|
28
28
|
|
|
29
29
|
## Install
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
`pm` is published on PyPI as `pushmetrics-cli`. Install it with [pipx](https://pipx.pypa.io/):
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
|
|
34
|
+
pipx install pushmetrics-cli
|
|
35
35
|
pm --version
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Requires Python 3.9 or newer
|
|
38
|
+
or with `uv tool install pushmetrics-cli` or `pip install --user pushmetrics-cli`. Requires Python 3.9 or newer and runs on macOS, Linux and Windows. Python from Homebrew and some Linux distributions refuses `pip install --user`; use pipx or uv there.
|
|
39
|
+
|
|
40
|
+
`pm` needs an active PushMetrics subscription for your workspace's organization. Without one, PushMetrics refuses your personal access token: `pm` cannot log in, its commands stop working when the current session expires or sooner (within an hour at most), and no new tokens can be created.
|
|
39
41
|
|
|
40
42
|
## Quick start
|
|
41
43
|
|
|
42
|
-
1. In the PushMetrics app, open **Settings →
|
|
44
|
+
1. In the PushMetrics app, open **Settings → CLI & API** (`/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
45
|
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
46
|
|
|
45
47
|
```bash
|
|
@@ -60,7 +62,7 @@ Requires Python 3.9 or newer. Runs on macOS, Linux and Windows.
|
|
|
60
62
|
|
|
61
63
|
In CI, skip `pm login` and set `PM_API_TOKEN` instead.
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
`pm` logs in with personal access tokens only; `pm login` without `--token` tells you where to create one.
|
|
64
66
|
|
|
65
67
|
## Commands
|
|
66
68
|
|
|
@@ -158,7 +160,9 @@ pm --profile acme report list
|
|
|
158
160
|
|
|
159
161
|
`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
162
|
|
|
161
|
-
`pm logout` removes both for the active profile. It does not revoke the token on the server; use `pm token revoke` or the
|
|
163
|
+
`pm logout` removes both for the active profile. It does not revoke the token on the server; use `pm token revoke` or the CLI & API page for that.
|
|
164
|
+
|
|
165
|
+
Profiles (api_url, auth0 settings) live in `$PM_CONFIG_HOME/config.toml` (default `~/.pm/config.toml`). `pm config set` writes it atomically (a temp file in the same directory is renamed into place), so an interrupted write never leaves a torn file. A config.toml created fresh by `pm` is mode `0600`; an existing file's mode is preserved. A symlinked config.toml keeps its link (the link target is updated).
|
|
162
166
|
|
|
163
167
|
`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
168
|
|
|
@@ -172,7 +176,7 @@ pm --profile acme report list
|
|
|
172
176
|
|
|
173
177
|
**`Could not reach https://... (ConnectionError)…`** Network, or the wrong address: `pm config list` shows it.
|
|
174
178
|
|
|
175
|
-
**`Unknown profile: 'acme'…`** or **`Profile 'acme' has no api_url…`** Each message
|
|
179
|
+
**`Unknown profile: 'acme'…`** or **`Profile 'acme' has no api_url…`** Each message names the `pm config set` command that fixes it; see also [Several workspaces](#several-workspaces).
|
|
176
180
|
|
|
177
181
|
**`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
182
|
|
|
@@ -8,18 +8,20 @@ The complete user guide is on the website: [PushMetrics CLI](https://pushmetrics
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
`pm` is published on PyPI as `pushmetrics-cli`. Install it with [pipx](https://pipx.pypa.io/):
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
|
|
14
|
+
pipx install pushmetrics-cli
|
|
15
15
|
pm --version
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Requires Python 3.9 or newer
|
|
18
|
+
or with `uv tool install pushmetrics-cli` or `pip install --user pushmetrics-cli`. Requires Python 3.9 or newer and runs on macOS, Linux and Windows. Python from Homebrew and some Linux distributions refuses `pip install --user`; use pipx or uv there.
|
|
19
|
+
|
|
20
|
+
`pm` needs an active PushMetrics subscription for your workspace's organization. Without one, PushMetrics refuses your personal access token: `pm` cannot log in, its commands stop working when the current session expires or sooner (within an hour at most), and no new tokens can be created.
|
|
19
21
|
|
|
20
22
|
## Quick start
|
|
21
23
|
|
|
22
|
-
1. In the PushMetrics app, open **Settings →
|
|
24
|
+
1. In the PushMetrics app, open **Settings → CLI & API** (`/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
25
|
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
26
|
|
|
25
27
|
```bash
|
|
@@ -40,7 +42,7 @@ Requires Python 3.9 or newer. Runs on macOS, Linux and Windows.
|
|
|
40
42
|
|
|
41
43
|
In CI, skip `pm login` and set `PM_API_TOKEN` instead.
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
`pm` logs in with personal access tokens only; `pm login` without `--token` tells you where to create one.
|
|
44
46
|
|
|
45
47
|
## Commands
|
|
46
48
|
|
|
@@ -138,7 +140,9 @@ pm --profile acme report list
|
|
|
138
140
|
|
|
139
141
|
`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
142
|
|
|
141
|
-
`pm logout` removes both for the active profile. It does not revoke the token on the server; use `pm token revoke` or the
|
|
143
|
+
`pm logout` removes both for the active profile. It does not revoke the token on the server; use `pm token revoke` or the CLI & API page for that.
|
|
144
|
+
|
|
145
|
+
Profiles (api_url, auth0 settings) live in `$PM_CONFIG_HOME/config.toml` (default `~/.pm/config.toml`). `pm config set` writes it atomically (a temp file in the same directory is renamed into place), so an interrupted write never leaves a torn file. A config.toml created fresh by `pm` is mode `0600`; an existing file's mode is preserved. A symlinked config.toml keeps its link (the link target is updated).
|
|
142
146
|
|
|
143
147
|
`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
148
|
|
|
@@ -152,7 +156,7 @@ pm --profile acme report list
|
|
|
152
156
|
|
|
153
157
|
**`Could not reach https://... (ConnectionError)…`** Network, or the wrong address: `pm config list` shows it.
|
|
154
158
|
|
|
155
|
-
**`Unknown profile: 'acme'…`** or **`Profile 'acme' has no api_url…`** Each message
|
|
159
|
+
**`Unknown profile: 'acme'…`** or **`Profile 'acme' has no api_url…`** Each message names the `pm config set` command that fixes it; see also [Several workspaces](#several-workspaces).
|
|
156
160
|
|
|
157
161
|
**`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
162
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -16,6 +16,7 @@ from pm.auth import device_flow, exchange, jwt_cache, store
|
|
|
16
16
|
from pm.auth import token as token_auth
|
|
17
17
|
from pm.config import Profile
|
|
18
18
|
from pm.log import log_request, log_response
|
|
19
|
+
from pm.user_agent import user_agent
|
|
19
20
|
|
|
20
21
|
logger = logging.getLogger(__name__)
|
|
21
22
|
|
|
@@ -231,6 +232,11 @@ class PMSession:
|
|
|
231
232
|
url = f"{self.profile.api_url}{path}"
|
|
232
233
|
kwargs.setdefault("timeout", 60)
|
|
233
234
|
headers = dict(kwargs.get("headers") or {})
|
|
235
|
+
# Declare the calling tool (PM_CLIENT) on every request, so the
|
|
236
|
+
# server can attribute usage to Claude Code / Copilot / raw CLI
|
|
237
|
+
# instead of a bare python-requests UA. setdefault: an explicit
|
|
238
|
+
# per-call User-Agent still wins.
|
|
239
|
+
headers.setdefault("User-Agent", user_agent())
|
|
234
240
|
# Forward agent identity to the API for backend scope filters.
|
|
235
241
|
if self._agent_uid:
|
|
236
242
|
headers.setdefault(_AGENT_UID_HEADER, self._agent_uid)
|
|
@@ -31,6 +31,7 @@ from pm.auth import jwt_cache
|
|
|
31
31
|
from pm.auth.secret_files import read_private_json, write_private_json
|
|
32
32
|
from pm.config import CONFIG_DIR, keyring_account
|
|
33
33
|
from pm.log import log_request, log_response, redact_secrets, timed
|
|
34
|
+
from pm.user_agent import user_agent
|
|
34
35
|
|
|
35
36
|
try:
|
|
36
37
|
import keyring
|
|
@@ -144,7 +145,12 @@ def exchange_pat_for_jwt(
|
|
|
144
145
|
try:
|
|
145
146
|
resp = session.post(
|
|
146
147
|
url,
|
|
147
|
-
headers={
|
|
148
|
+
headers={
|
|
149
|
+
"Authorization": f"Bearer {raw_token}",
|
|
150
|
+
# Declares the calling tool (PM_CLIENT) so the server's
|
|
151
|
+
# pat_exchanged audit row names it; see pm.user_agent.
|
|
152
|
+
"User-Agent": user_agent(),
|
|
153
|
+
},
|
|
148
154
|
timeout=30,
|
|
149
155
|
# Never follow a redirect: ``requests`` would re-attach the
|
|
150
156
|
# Authorization header (and thus the raw PAT) on a same-host 302.
|
|
@@ -11,6 +11,8 @@ import click
|
|
|
11
11
|
|
|
12
12
|
from pm._alias import AliasedGroup
|
|
13
13
|
from pm import __version__
|
|
14
|
+
from pm import config as pm_config
|
|
15
|
+
from pm.auth.session import NotAuthenticatedError
|
|
14
16
|
from pm.client.base import PMApiError
|
|
15
17
|
from pm.commands._helpers import info, json_option
|
|
16
18
|
from pm.config import (
|
|
@@ -22,6 +24,7 @@ from pm.config import (
|
|
|
22
24
|
)
|
|
23
25
|
from pm.errors import describe_api_error
|
|
24
26
|
from pm.output import emit
|
|
27
|
+
from pm.user_agent import CLIENT_ENV_VAR, client_name
|
|
25
28
|
|
|
26
29
|
_cli_logger = logging.getLogger("pm.cli")
|
|
27
30
|
|
|
@@ -56,6 +59,15 @@ class RootGroup(AliasedGroup):
|
|
|
56
59
|
def invoke(self, ctx: click.Context):
|
|
57
60
|
try:
|
|
58
61
|
return super().invoke(ctx)
|
|
62
|
+
except NotAuthenticatedError as exc:
|
|
63
|
+
# A login made under another config home lives in another keyring
|
|
64
|
+
# slot (security S9); say which home this command used, so the
|
|
65
|
+
# mismatch is diagnosable. The default home needs no explanation.
|
|
66
|
+
if exc.message.startswith("Not logged in.") and not pm_config.is_default_config_home():
|
|
67
|
+
raise click.ClickException(
|
|
68
|
+
f"{exc.message}\nConfig home: {pm_config.describe_config_home()}."
|
|
69
|
+
)
|
|
70
|
+
raise
|
|
59
71
|
except PMApiError as exc:
|
|
60
72
|
raise click.ClickException(
|
|
61
73
|
describe_api_error(exc, group=ctx.invoked_subcommand)
|
|
@@ -64,7 +76,7 @@ class RootGroup(AliasedGroup):
|
|
|
64
76
|
|
|
65
77
|
_GETTING_STARTED = """\b
|
|
66
78
|
Get started with a personal access token:
|
|
67
|
-
1. Create a token in the
|
|
79
|
+
1. Create a token in the web app under Settings > CLI & API.
|
|
68
80
|
2. pm login --token paste the token at the prompt (stored securely)
|
|
69
81
|
3. pm whoami then: pm report list, pm run list, pm report run <uid>
|
|
70
82
|
"""
|
|
@@ -182,13 +194,21 @@ def config_list(ctx: click.Context) -> None:
|
|
|
182
194
|
("PM_API_URL", os.environ.get("PM_API_URL")),
|
|
183
195
|
("PM_AUTH0_DOMAIN", os.environ.get("PM_AUTH0_DOMAIN")),
|
|
184
196
|
("PM_AUTH0_CLIENT_ID", os.environ.get("PM_AUTH0_CLIENT_ID")),
|
|
197
|
+
("PM_CONFIG_HOME", os.environ.get("PM_CONFIG_HOME")),
|
|
198
|
+
# Not a profile setting, but it changes what pm sends: the
|
|
199
|
+
# User-Agent's client= names the calling tool.
|
|
200
|
+
(CLIENT_ENV_VAR, os.environ.get(CLIENT_ENV_VAR)),
|
|
185
201
|
)
|
|
186
202
|
if value
|
|
187
203
|
}
|
|
188
204
|
|
|
189
205
|
if ctx.obj["json"]:
|
|
190
206
|
emit(
|
|
191
|
-
{
|
|
207
|
+
{
|
|
208
|
+
"profiles": rows,
|
|
209
|
+
"env_overrides": env_overrides,
|
|
210
|
+
"config_home": str(pm_config.CONFIG_DIR),
|
|
211
|
+
},
|
|
192
212
|
json_output=True,
|
|
193
213
|
)
|
|
194
214
|
return
|
|
@@ -205,11 +225,20 @@ def config_list(ctx: click.Context) -> None:
|
|
|
205
225
|
],
|
|
206
226
|
)
|
|
207
227
|
|
|
228
|
+
# Which config home is active decides which stored credentials (keyring
|
|
229
|
+
# slot) commands can see (security S9).
|
|
230
|
+
click.echo("")
|
|
231
|
+
click.echo(f"Config home: {pm_config.describe_config_home()}")
|
|
232
|
+
|
|
208
233
|
if env_overrides:
|
|
209
234
|
click.echo("")
|
|
210
235
|
click.echo("Active env-var overrides (apply to all profiles):")
|
|
211
236
|
for var, value in env_overrides.items():
|
|
212
|
-
|
|
237
|
+
line = f" {var}={value}"
|
|
238
|
+
if var == CLIENT_ENV_VAR and client_name() != value:
|
|
239
|
+
# pm normalises the value; show what the server receives.
|
|
240
|
+
line += f" (sent as client={client_name()})"
|
|
241
|
+
click.echo(line)
|
|
213
242
|
|
|
214
243
|
|
|
215
244
|
from pm.commands.auth import login, logout, whoami
|
|
@@ -225,6 +254,8 @@ from pm.commands.integrations import integrations as integrations_group
|
|
|
225
254
|
from pm.commands.metrics_view import metrics_view as metrics_view_group
|
|
226
255
|
from pm.commands.organizations import organizations as organizations_group
|
|
227
256
|
from pm.commands.recipient import recipient as recipient_group
|
|
257
|
+
from pm.commands.deliverability import deliverability as deliverability_group
|
|
258
|
+
from pm.commands.schedule import schedule as schedule_group
|
|
228
259
|
from pm.commands.reports import reports as reports_group
|
|
229
260
|
from pm.commands.roles import roles as roles_group
|
|
230
261
|
from pm.commands.runs import runs as runs_group
|
|
@@ -263,6 +294,8 @@ cli.add_command(webhooks_group, name="webhook")
|
|
|
263
294
|
cli.add_command(favorites_group, name="favorite")
|
|
264
295
|
cli.add_command(organizations_group, name="organization")
|
|
265
296
|
cli.add_command(recipient_group)
|
|
297
|
+
cli.add_command(deliverability_group)
|
|
298
|
+
cli.add_command(schedule_group)
|
|
266
299
|
cli.add_command(roles_group, name="role")
|
|
267
300
|
cli.add_command(saved_blocks_group, name="saved-block")
|
|
268
301
|
|
|
@@ -14,6 +14,7 @@ from pm.auth.session import PMSession
|
|
|
14
14
|
from pm.client.access_tokens import AccessTokensClient
|
|
15
15
|
from pm.client.blocks import BlocksClient
|
|
16
16
|
from pm.client.databases import DatabasesClient
|
|
17
|
+
from pm.client.deliverability import DeliverabilityClient
|
|
17
18
|
from pm.client.favorites import FavoritesClient
|
|
18
19
|
from pm.client.folders import FoldersClient
|
|
19
20
|
from pm.client.integrations import IntegrationsClient
|
|
@@ -23,6 +24,7 @@ from pm.client.reports import ReportsClient
|
|
|
23
24
|
from pm.client.roles import RolesClient
|
|
24
25
|
from pm.client.runs import RunsClient
|
|
25
26
|
from pm.client.saved_blocks import SavedBlocksClient
|
|
27
|
+
from pm.client.schedules import SchedulesClient
|
|
26
28
|
from pm.client.tableau import TableauClient
|
|
27
29
|
from pm.client.users import UsersClient
|
|
28
30
|
from pm.client.webhooks import WebhooksClient
|
|
@@ -50,8 +52,10 @@ class PMClient:
|
|
|
50
52
|
self.favorites = FavoritesClient(session)
|
|
51
53
|
self.organizations = OrganizationsClient(session)
|
|
52
54
|
self.recipients = RecipientsClient(session)
|
|
55
|
+
self.deliverability = DeliverabilityClient(session)
|
|
53
56
|
self.roles = RolesClient(session)
|
|
54
57
|
self.saved_blocks = SavedBlocksClient(session)
|
|
58
|
+
self.schedules = SchedulesClient(session)
|
|
55
59
|
self.blocks = BlocksClient(session)
|
|
56
60
|
self.tableau = TableauClient(session)
|
|
57
61
|
self.access_tokens = AccessTokensClient(session)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Email deliverability API client: wraps ``/api/v1/recipient/deliverability*``.
|
|
2
|
+
|
|
3
|
+
Amazon SES keeps one suppression list per sending account; the server scopes
|
|
4
|
+
it to this workspace by matching only the workspace's own recipients, users
|
|
5
|
+
and email blocks (``superset/pushmetrics/views/recipient/api.py``). The client
|
|
6
|
+
returns the response bodies as they are; the command layer renders them.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict
|
|
11
|
+
|
|
12
|
+
from pm.client.base import raise_for_status
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DeliverabilityClient:
|
|
16
|
+
base_path = "/api/v1/recipient/deliverability"
|
|
17
|
+
|
|
18
|
+
def __init__(self, session):
|
|
19
|
+
self.session = session
|
|
20
|
+
|
|
21
|
+
def list(self) -> Dict[str, Any]:
|
|
22
|
+
"""``{result: [blocked rows], count, enabled}``."""
|
|
23
|
+
resp = self.session.get(self.base_path)
|
|
24
|
+
raise_for_status(resp)
|
|
25
|
+
return resp.json() if resp.content else {}
|
|
26
|
+
|
|
27
|
+
def events(self, email: str) -> Dict[str, Any]:
|
|
28
|
+
"""``{result: [events, newest first], state}`` for one address."""
|
|
29
|
+
resp = self.session.get(f"{self.base_path}/events", params={"email": email})
|
|
30
|
+
raise_for_status(resp)
|
|
31
|
+
return resp.json() if resp.content else {}
|
|
32
|
+
|
|
33
|
+
def unblock(self, email: str) -> Dict[str, Any]:
|
|
34
|
+
"""``{result: state}``. Admin-only, enforced by the server."""
|
|
35
|
+
resp = self.session.post(f"{self.base_path}/unblock", json={"email": email})
|
|
36
|
+
raise_for_status(resp)
|
|
37
|
+
return resp.json() if resp.content else {}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Schedules API client: wraps ``/api/v1/workflow`` (a "workflow" in the API,
|
|
2
|
+
a "schedule" everywhere a person reads it).
|
|
3
|
+
|
|
4
|
+
``superset/pushmetrics/views/workflow/api.py``: list/get return the same
|
|
5
|
+
record (``show_columns = list_columns``, report name and uid nested under
|
|
6
|
+
``report``); POST takes ``report_id`` (the integer id, which the command layer
|
|
7
|
+
resolves from a report uid); PUT takes ``rrule`` and ``active``; ``POST
|
|
8
|
+
/<uid>/run`` dispatches the run as a background task and returns no run uid.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from pm.client.base import _unwrap_result, raise_for_status
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SchedulesClient:
|
|
18
|
+
base_path = "/api/v1/workflow"
|
|
19
|
+
|
|
20
|
+
def __init__(self, session):
|
|
21
|
+
self.session = session
|
|
22
|
+
|
|
23
|
+
def list(self, report_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
|
24
|
+
params = {"q": "(page_size:-1)"}
|
|
25
|
+
if report_id is not None:
|
|
26
|
+
params["q"] = f"(filters:!((col:report_id,opr:eq,value:{int(report_id)})),page_size:-1)"
|
|
27
|
+
resp = self.session.get(f"{self.base_path}/", params=params)
|
|
28
|
+
raise_for_status(resp)
|
|
29
|
+
raw = _unwrap_result(resp.json(), default=[])
|
|
30
|
+
return raw if isinstance(raw, list) else [raw]
|
|
31
|
+
|
|
32
|
+
def get(self, uid: str) -> Dict[str, Any]:
|
|
33
|
+
resp = self.session.get(f"{self.base_path}/{uid}")
|
|
34
|
+
raise_for_status(resp)
|
|
35
|
+
return _unwrap_result(resp.json(), default={}) or {}
|
|
36
|
+
|
|
37
|
+
def create(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
38
|
+
"""The add response carries only add_columns; callers re-read with get."""
|
|
39
|
+
resp = self.session.post(f"{self.base_path}/", json=data)
|
|
40
|
+
raise_for_status(resp)
|
|
41
|
+
return _unwrap_result(resp.json(), default={}) or {}
|
|
42
|
+
|
|
43
|
+
def update(self, uid: str, data: Dict[str, Any]) -> None:
|
|
44
|
+
resp = self.session.put(f"{self.base_path}/{uid}", json=data)
|
|
45
|
+
raise_for_status(resp)
|
|
46
|
+
|
|
47
|
+
def run(self, uid: str) -> None:
|
|
48
|
+
resp = self.session.post(f"{self.base_path}/{uid}/run")
|
|
49
|
+
raise_for_status(resp)
|
|
@@ -2,14 +2,30 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
5
7
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Union
|
|
6
8
|
|
|
7
9
|
import click
|
|
8
10
|
|
|
9
11
|
from pm.client.base import PMApiError
|
|
12
|
+
from pm.output import _safe_text
|
|
10
13
|
|
|
11
14
|
YES_HELP = "Skip the confirmation prompt. Required when stdin is not a terminal."
|
|
12
15
|
|
|
16
|
+
_FRACTION = re.compile(r"\.\d+(?=([+-]\d{2}:?\d{2}|Z)?$)")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def iso_seconds(value: Optional[str]) -> str:
|
|
20
|
+
"""An ISO timestamp without microseconds; blank when empty."""
|
|
21
|
+
return _FRACTION.sub("", value) if value else ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def stdin_is_terminal() -> bool:
|
|
25
|
+
"""Whether a person can answer a prompt. Commands that need --yes off a
|
|
26
|
+
terminal check this BEFORE prompting, so a piped "y" cannot pass."""
|
|
27
|
+
return sys.stdin.isatty()
|
|
28
|
+
|
|
13
29
|
|
|
14
30
|
def json_option(f):
|
|
15
31
|
"""Per-command ``--json`` so both ``pm --json report list`` and
|
|
@@ -123,7 +139,7 @@ def peek_name(fetch: Callable[[], Any], *fields: str) -> Optional[str]:
|
|
|
123
139
|
|
|
124
140
|
def confirm_delete(noun: str, uid: str, name: Optional[str] = None, *, note: str = "") -> None:
|
|
125
141
|
"""``Delete <noun> 'name' (uid)? [y/N]`` — abort on anything but yes."""
|
|
126
|
-
label = f"{name!r} ({uid})" if name else uid
|
|
142
|
+
label = f"{_safe_text(name)!r} ({uid})" if name else uid
|
|
127
143
|
tail = f" {note}" if note else ""
|
|
128
144
|
click.confirm(f"Delete {noun} {label}?{tail}", abort=True)
|
|
129
145
|
|
|
@@ -13,7 +13,7 @@ from pm.auth.session import PMSession
|
|
|
13
13
|
from pm.client import PMClient
|
|
14
14
|
from pm.client.base import PMApiError
|
|
15
15
|
from pm.commands._helpers import info, json_option
|
|
16
|
-
from pm.output import emit
|
|
16
|
+
from pm.output import _safe_text, emit
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def _read_token(ctx: click.Context) -> str:
|
|
@@ -29,7 +29,7 @@ def _read_token(ctx: click.Context) -> str:
|
|
|
29
29
|
|
|
30
30
|
def tokens_page_url(api_url: str) -> str:
|
|
31
31
|
"""The web app page where personal access tokens are created and revoked
|
|
32
|
-
(frontend route ``/a/tokens``, menu label "
|
|
32
|
+
(frontend route ``/a/tokens``, menu label "CLI & API")."""
|
|
33
33
|
return f"{api_url}/a/tokens"
|
|
34
34
|
|
|
35
35
|
|
|
@@ -41,9 +41,9 @@ def _token_login_failure(exc: token_auth.TokenExchangeError, api_url: str) -> st
|
|
|
41
41
|
"Too many login attempts from this network address "
|
|
42
42
|
"(10 per minute). Wait a minute and run `pm login --token` again."
|
|
43
43
|
)
|
|
44
|
-
if exc.status is None:
|
|
45
|
-
# Raised before any HTTP (malformed token, unreachable host)
|
|
46
|
-
# message already names the next action.
|
|
44
|
+
if exc.status is None or exc.status == 402:
|
|
45
|
+
# Raised before any HTTP (malformed token, unreachable host), or the
|
|
46
|
+
# subscription gate: the message already names the next action.
|
|
47
47
|
return str(exc)
|
|
48
48
|
return f"{exc}\nHint: tokens are created and revoked at {tokens_page_url(api_url)}"
|
|
49
49
|
|
|
@@ -91,8 +91,9 @@ def _login_with_token(ctx: click.Context, profile) -> None:
|
|
|
91
91
|
def login(ctx: click.Context, no_browser: bool, use_token: bool) -> None:
|
|
92
92
|
"""Log in with a personal access token.
|
|
93
93
|
|
|
94
|
-
pm login --token
|
|
95
|
-
|
|
94
|
+
Run pm login --token and paste the token when asked, or pipe it in from
|
|
95
|
+
your secrets manager. It is stored for this profile. Without --token,
|
|
96
|
+
pm login prints where to create a token.
|
|
96
97
|
"""
|
|
97
98
|
profile = ctx.obj["profile"]
|
|
98
99
|
|
|
@@ -121,9 +122,9 @@ def login(ctx: click.Context, no_browser: bool, use_token: bool) -> None:
|
|
|
121
122
|
info(ctx, "")
|
|
122
123
|
info(
|
|
123
124
|
ctx,
|
|
124
|
-
f"Open this URL on any device: {device_payload.verification_uri_complete}",
|
|
125
|
+
_safe_text(f"Open this URL on any device: {device_payload.verification_uri_complete}"),
|
|
125
126
|
)
|
|
126
|
-
info(ctx, f"Verify it shows the user code: {device_payload.user_code}")
|
|
127
|
+
info(ctx, _safe_text(f"Verify it shows the user code: {device_payload.user_code}"))
|
|
127
128
|
info(ctx, "Waiting for authorization...")
|
|
128
129
|
|
|
129
130
|
try:
|
|
@@ -8,7 +8,7 @@ import click
|
|
|
8
8
|
from pm._alias import AliasedGroup
|
|
9
9
|
from pm.client import PMClient
|
|
10
10
|
from pm.commands._helpers import json_option, limit_option, name_filter_option, select_rows
|
|
11
|
-
from pm.output import emit
|
|
11
|
+
from pm.output import _safe_lines, _safe_text, emit
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@click.group(name="db", cls=AliasedGroup, aliases={"ls": "list"})
|
|
@@ -120,7 +120,7 @@ def describe(ctx: click.Context, db_ref: str, schema: str, table: str) -> None:
|
|
|
120
120
|
|
|
121
121
|
click.echo(f"Table: {schema}.{table}")
|
|
122
122
|
if meta.get("comment"):
|
|
123
|
-
click.echo(f"Comment: {meta['comment']}")
|
|
123
|
+
click.echo(_safe_lines(f"Comment: {meta['comment']}"))
|
|
124
124
|
|
|
125
125
|
cols = meta.get("columns") or []
|
|
126
126
|
if cols:
|
|
@@ -148,7 +148,7 @@ def describe(ctx: click.Context, db_ref: str, schema: str, table: str) -> None:
|
|
|
148
148
|
ref_cols = ", ".join(fk.get("referred_columns") or [])
|
|
149
149
|
ref_schema = fk.get("referred_schema") or ""
|
|
150
150
|
ref_full = f"{ref_schema}.{ref_table}" if ref_schema else ref_table
|
|
151
|
-
click.echo(f" ({src}) -> {ref_full}({ref_cols})")
|
|
151
|
+
click.echo(_safe_text(f" ({src}) -> {ref_full}({ref_cols})"))
|
|
152
152
|
|
|
153
153
|
|
|
154
154
|
@db.command(name="select-star")
|
|
@@ -178,7 +178,7 @@ def select_star(
|
|
|
178
178
|
if json_output:
|
|
179
179
|
emit({"sql": sql}, json_output=True)
|
|
180
180
|
return
|
|
181
|
-
click.echo(sql)
|
|
181
|
+
click.echo(_safe_lines(sql))
|
|
182
182
|
|
|
183
183
|
|
|
184
184
|
@db.command(name="exists")
|
|
@@ -209,7 +209,7 @@ def exists(ctx: click.Context, db_ref: str, schema: str, table: str) -> None:
|
|
|
209
209
|
return
|
|
210
210
|
kind = info.get("type") or "-"
|
|
211
211
|
resolved = info.get("resolved_name") or "-"
|
|
212
|
-
click.echo(f"exists={info.get('exists')} type={kind} resolved={resolved}")
|
|
212
|
+
click.echo(_safe_text(f"exists={info.get('exists')} type={kind} resolved={resolved}"))
|
|
213
213
|
if not info.get("exists"):
|
|
214
214
|
ctx.exit(1)
|
|
215
215
|
|
|
@@ -246,11 +246,11 @@ def functions(ctx: click.Context, db_ref: str, filter_: str, limit: int) -> None
|
|
|
246
246
|
|
|
247
247
|
note = result.get("note")
|
|
248
248
|
if note:
|
|
249
|
-
click.echo(f"Note: {note}")
|
|
249
|
+
click.echo(_safe_lines(f"Note: {note}"))
|
|
250
250
|
return
|
|
251
251
|
|
|
252
252
|
for name in result.get("functions") or []:
|
|
253
|
-
click.echo(name)
|
|
253
|
+
click.echo(_safe_text(str(name)))
|
|
254
254
|
total = result.get("total", 0)
|
|
255
255
|
count = result.get("count", 0)
|
|
256
256
|
if total and count < total:
|