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.
Files changed (156) hide show
  1. {pushmetrics_cli-0.1.0/pushmetrics_cli.egg-info → pushmetrics_cli-0.2.0}/PKG-INFO +12 -8
  2. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/README.md +11 -7
  3. pushmetrics_cli-0.2.0/pm/__init__.py +1 -0
  4. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/session.py +6 -0
  5. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/token.py +7 -1
  6. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/cli.py +36 -3
  7. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/__init__.py +4 -0
  8. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/blocks.py +1 -0
  9. pushmetrics_cli-0.2.0/pm/client/deliverability.py +37 -0
  10. pushmetrics_cli-0.2.0/pm/client/schedules.py +49 -0
  11. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/_helpers.py +17 -1
  12. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/auth.py +10 -9
  13. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/db.py +7 -7
  14. pushmetrics_cli-0.2.0/pm/commands/deliverability.py +182 -0
  15. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/help.py +30 -2
  16. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/reports.py +6 -5
  17. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/roles.py +3 -3
  18. pushmetrics_cli-0.2.0/pm/commands/schedule.py +313 -0
  19. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/users.py +2 -2
  20. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/config.py +62 -2
  21. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/errors.py +25 -1
  22. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/output.py +28 -2
  23. pushmetrics_cli-0.2.0/pm/user_agent.py +67 -0
  24. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0/pushmetrics_cli.egg-info}/PKG-INFO +12 -8
  25. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/SOURCES.txt +15 -0
  26. pushmetrics_cli-0.2.0/tests/test_api_line_keeps_lines.py +75 -0
  27. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_blocks.py +45 -0
  28. pushmetrics_cli-0.2.0/tests/test_config_atomic.py +127 -0
  29. pushmetrics_cli-0.2.0/tests/test_deliverability_commands.py +225 -0
  30. pushmetrics_cli-0.2.0/tests/test_e2_no_domain.py +23 -0
  31. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_first_run.py +193 -1
  32. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_help_command.py +7 -7
  33. pushmetrics_cli-0.2.0/tests/test_help_encoding.py +50 -0
  34. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_keyring_namespace.py +0 -1
  35. pushmetrics_cli-0.2.0/tests/test_multiline_control_chars.py +173 -0
  36. pushmetrics_cli-0.2.0/tests/test_output_control_chars.py +47 -0
  37. pushmetrics_cli-0.2.0/tests/test_schedule_commands.py +432 -0
  38. pushmetrics_cli-0.2.0/tests/test_server_names_sanitised.py +75 -0
  39. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_session.py +29 -0
  40. pushmetrics_cli-0.2.0/tests/test_user_agent.py +55 -0
  41. pushmetrics_cli-0.1.0/pm/__init__.py +0 -1
  42. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/MANIFEST.in +0 -0
  43. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/__main__.py +0 -0
  44. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/_alias.py +0 -0
  45. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/__init__.py +0 -0
  46. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/device_flow.py +0 -0
  47. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/exchange.py +0 -0
  48. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/jwt_cache.py +0 -0
  49. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/secret_files.py +0 -0
  50. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/auth/store.py +0 -0
  51. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/access_tokens.py +0 -0
  52. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/base.py +0 -0
  53. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/databases.py +0 -0
  54. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/favorites.py +0 -0
  55. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/folders.py +0 -0
  56. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/integrations.py +0 -0
  57. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/organizations.py +0 -0
  58. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/recipients.py +0 -0
  59. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/reports.py +0 -0
  60. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/roles.py +0 -0
  61. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/runs.py +0 -0
  62. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/saved_blocks.py +0 -0
  63. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/tableau.py +0 -0
  64. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/users.py +0 -0
  65. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/webhooks.py +0 -0
  66. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/client/workspaces.py +0 -0
  67. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/__init__.py +0 -0
  68. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/_block_factory.py +0 -0
  69. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/block.py +0 -0
  70. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/chart.py +0 -0
  71. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/email.py +0 -0
  72. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/explore.py +0 -0
  73. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/favorites.py +0 -0
  74. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/folders.py +0 -0
  75. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/integrations.py +0 -0
  76. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/metrics_view.py +0 -0
  77. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/organizations.py +0 -0
  78. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/recipient.py +0 -0
  79. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/runs.py +0 -0
  80. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/saved_blocks.py +0 -0
  81. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/sftp.py +0 -0
  82. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/slack.py +0 -0
  83. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/slides.py +0 -0
  84. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/sql.py +0 -0
  85. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/tableau.py +0 -0
  86. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/token.py +0 -0
  87. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/webhooks.py +0 -0
  88. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/commands/workspace.py +0 -0
  89. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/log.py +0 -0
  90. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/__init__.py +0 -0
  91. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/access_tokens.py +0 -0
  92. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/blocks.py +0 -0
  93. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/databases.py +0 -0
  94. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/favorites.py +0 -0
  95. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/folders.py +0 -0
  96. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/integrations.py +0 -0
  97. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/organizations.py +0 -0
  98. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/recipients.py +0 -0
  99. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/reports.py +0 -0
  100. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/roles.py +0 -0
  101. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/runs.py +0 -0
  102. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/saved_blocks.py +0 -0
  103. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/users.py +0 -0
  104. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/webhooks.py +0 -0
  105. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/models/workspaces.py +0 -0
  106. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/types.py +0 -0
  107. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pm/validators.py +0 -0
  108. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/dependency_links.txt +0 -0
  109. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/entry_points.txt +0 -0
  110. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/requires.txt +0 -0
  111. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/top_level.txt +0 -0
  112. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/pyproject.toml +0 -0
  113. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/setup.cfg +0 -0
  114. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/__init__.py +0 -0
  115. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/_runner.py +0 -0
  116. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/conftest.py +0 -0
  117. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_agent_profile.py +0 -0
  118. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_aliases.py +0 -0
  119. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_block_factory.py +0 -0
  120. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_access_tokens.py +0 -0
  121. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_favorites.py +0 -0
  122. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_folders.py +0 -0
  123. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_integrations.py +0 -0
  124. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_organizations.py +0 -0
  125. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_recipients.py +0 -0
  126. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_reports.py +0 -0
  127. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_runs.py +0 -0
  128. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_saved_blocks.py +0 -0
  129. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_tableau.py +0 -0
  130. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_users.py +0 -0
  131. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_client_webhooks.py +0 -0
  132. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_credential_safety.py +0 -0
  133. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_device_flow.py +0 -0
  134. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_did_you_mean.py +0 -0
  135. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_domain_groups.py +0 -0
  136. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_errors.py +0 -0
  137. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_hardening.py +0 -0
  138. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_integration_commands.py +0 -0
  139. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_jwt_cache.py +0 -0
  140. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_list_limit.py +0 -0
  141. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_metrics_view_commands.py +0 -0
  142. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_no_backend_imports.py +0 -0
  143. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_packaging.py +0 -0
  144. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_pm_client.py +0 -0
  145. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_prod_run_fixes.py +0 -0
  146. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_profile_pat_only.py +0 -0
  147. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_quiet_flag.py +0 -0
  148. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_recipient_commands.py +0 -0
  149. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_report_preview_commands.py +0 -0
  150. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_secret_files.py +0 -0
  151. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_slides_commands.py +0 -0
  152. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_tableau_commands.py +0 -0
  153. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_token_auth.py +0 -0
  154. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_token_commands.py +0 -0
  155. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.2.0}/tests/test_types.py +0 -0
  156. {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.1.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
- > **PLACEHOLDER (D1).** The install command depends on the distribution channel and is filled in at release time.
31
+ `pm` is published on PyPI as `pushmetrics-cli`. Install it with [pipx](https://pipx.pypa.io/):
32
32
 
33
33
  ```bash
34
- # PLACEHOLDER (D1): install command goes here
34
+ pipx install pushmetrics-cli
35
35
  pm --version
36
36
  ```
37
37
 
38
- Requires Python 3.9 or newer. Runs on macOS, Linux and Windows.
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 → 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.
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
- > **PLACEHOLDER (D2).** Browser login (`pm login` without `--token`, Auth0 device flow) is documented here only if it ships in the first release.
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 Access Tokens page for that.
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 ends with the `pm config set` command that fixes it; see also [Several workspaces](#several-workspaces).
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
- > **PLACEHOLDER (D1).** The install command depends on the distribution channel and is filled in at release time.
11
+ `pm` is published on PyPI as `pushmetrics-cli`. Install it with [pipx](https://pipx.pypa.io/):
12
12
 
13
13
  ```bash
14
- # PLACEHOLDER (D1): install command goes here
14
+ pipx install pushmetrics-cli
15
15
  pm --version
16
16
  ```
17
17
 
18
- Requires Python 3.9 or newer. Runs on macOS, Linux and Windows.
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 → 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.
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
- > **PLACEHOLDER (D2).** Browser login (`pm login` without `--token`, Auth0 device flow) is documented here only if it ships in the first release.
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 Access Tokens page for that.
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 ends with the `pm config set` command that fixes it; see also [Several workspaces](#several-workspaces).
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={"Authorization": f"Bearer {raw_token}"},
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 PushMetrics web app under Access Tokens.
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
- {"profiles": rows, "env_overrides": env_overrides},
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
- click.echo(f" {var}={value}")
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)
@@ -56,6 +56,7 @@ SCRATCH_ROUTING_FIELD: Dict[str, str] = {
56
56
  "slack": "integration_id",
57
57
  "sftp": "integration_id",
58
58
  "tableau": "integration_id",
59
+ "power_bi": "integration_id",
59
60
  "webhook": "integration_id",
60
61
  "google_slides": "integration_id",
61
62
  }
@@ -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 "Access Tokens")."""
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): the
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 stores the token for this profile. Without --token the
95
- browser login is used, which needs an Auth0 client id on the profile.
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: