pushmetrics-cli 0.1.0__tar.gz → 0.1.1__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 (146) hide show
  1. {pushmetrics_cli-0.1.0/pushmetrics_cli.egg-info → pushmetrics_cli-0.1.1}/PKG-INFO +12 -8
  2. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/README.md +11 -7
  3. pushmetrics_cli-0.1.1/pm/__init__.py +1 -0
  4. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/session.py +6 -0
  5. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/token.py +7 -1
  6. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/cli.py +23 -2
  7. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/auth.py +7 -6
  8. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/help.py +2 -2
  9. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/config.py +62 -2
  10. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/errors.py +10 -1
  11. pushmetrics_cli-0.1.1/pm/user_agent.py +67 -0
  12. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1/pushmetrics_cli.egg-info}/PKG-INFO +12 -8
  13. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pushmetrics_cli.egg-info/SOURCES.txt +5 -0
  14. pushmetrics_cli-0.1.1/tests/test_config_atomic.py +127 -0
  15. pushmetrics_cli-0.1.1/tests/test_e2_no_domain.py +23 -0
  16. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_first_run.py +145 -1
  17. pushmetrics_cli-0.1.1/tests/test_help_encoding.py +50 -0
  18. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_keyring_namespace.py +0 -1
  19. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_session.py +29 -0
  20. pushmetrics_cli-0.1.1/tests/test_user_agent.py +55 -0
  21. pushmetrics_cli-0.1.0/pm/__init__.py +0 -1
  22. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/MANIFEST.in +0 -0
  23. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/__main__.py +0 -0
  24. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/_alias.py +0 -0
  25. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/__init__.py +0 -0
  26. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/device_flow.py +0 -0
  27. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/exchange.py +0 -0
  28. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/jwt_cache.py +0 -0
  29. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/secret_files.py +0 -0
  30. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/store.py +0 -0
  31. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/__init__.py +0 -0
  32. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/access_tokens.py +0 -0
  33. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/base.py +0 -0
  34. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/blocks.py +0 -0
  35. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/databases.py +0 -0
  36. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/favorites.py +0 -0
  37. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/folders.py +0 -0
  38. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/integrations.py +0 -0
  39. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/organizations.py +0 -0
  40. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/recipients.py +0 -0
  41. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/reports.py +0 -0
  42. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/roles.py +0 -0
  43. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/runs.py +0 -0
  44. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/saved_blocks.py +0 -0
  45. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/tableau.py +0 -0
  46. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/users.py +0 -0
  47. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/webhooks.py +0 -0
  48. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/workspaces.py +0 -0
  49. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/__init__.py +0 -0
  50. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/_block_factory.py +0 -0
  51. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/_helpers.py +0 -0
  52. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/block.py +0 -0
  53. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/chart.py +0 -0
  54. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/db.py +0 -0
  55. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/email.py +0 -0
  56. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/explore.py +0 -0
  57. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/favorites.py +0 -0
  58. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/folders.py +0 -0
  59. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/integrations.py +0 -0
  60. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/metrics_view.py +0 -0
  61. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/organizations.py +0 -0
  62. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/recipient.py +0 -0
  63. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/reports.py +0 -0
  64. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/roles.py +0 -0
  65. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/runs.py +0 -0
  66. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/saved_blocks.py +0 -0
  67. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/sftp.py +0 -0
  68. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/slack.py +0 -0
  69. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/slides.py +0 -0
  70. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/sql.py +0 -0
  71. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/tableau.py +0 -0
  72. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/token.py +0 -0
  73. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/users.py +0 -0
  74. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/webhooks.py +0 -0
  75. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/workspace.py +0 -0
  76. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/log.py +0 -0
  77. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/__init__.py +0 -0
  78. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/access_tokens.py +0 -0
  79. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/blocks.py +0 -0
  80. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/databases.py +0 -0
  81. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/favorites.py +0 -0
  82. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/folders.py +0 -0
  83. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/integrations.py +0 -0
  84. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/organizations.py +0 -0
  85. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/recipients.py +0 -0
  86. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/reports.py +0 -0
  87. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/roles.py +0 -0
  88. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/runs.py +0 -0
  89. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/saved_blocks.py +0 -0
  90. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/users.py +0 -0
  91. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/webhooks.py +0 -0
  92. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/workspaces.py +0 -0
  93. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/output.py +0 -0
  94. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/types.py +0 -0
  95. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/validators.py +0 -0
  96. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pushmetrics_cli.egg-info/dependency_links.txt +0 -0
  97. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pushmetrics_cli.egg-info/entry_points.txt +0 -0
  98. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pushmetrics_cli.egg-info/requires.txt +0 -0
  99. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pushmetrics_cli.egg-info/top_level.txt +0 -0
  100. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pyproject.toml +0 -0
  101. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/setup.cfg +0 -0
  102. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/__init__.py +0 -0
  103. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/_runner.py +0 -0
  104. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/conftest.py +0 -0
  105. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_agent_profile.py +0 -0
  106. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_aliases.py +0 -0
  107. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_block_factory.py +0 -0
  108. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_access_tokens.py +0 -0
  109. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_blocks.py +0 -0
  110. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_favorites.py +0 -0
  111. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_folders.py +0 -0
  112. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_integrations.py +0 -0
  113. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_organizations.py +0 -0
  114. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_recipients.py +0 -0
  115. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_reports.py +0 -0
  116. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_runs.py +0 -0
  117. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_saved_blocks.py +0 -0
  118. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_tableau.py +0 -0
  119. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_users.py +0 -0
  120. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_webhooks.py +0 -0
  121. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_credential_safety.py +0 -0
  122. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_device_flow.py +0 -0
  123. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_did_you_mean.py +0 -0
  124. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_domain_groups.py +0 -0
  125. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_errors.py +0 -0
  126. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_hardening.py +0 -0
  127. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_help_command.py +0 -0
  128. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_integration_commands.py +0 -0
  129. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_jwt_cache.py +0 -0
  130. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_list_limit.py +0 -0
  131. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_metrics_view_commands.py +0 -0
  132. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_no_backend_imports.py +0 -0
  133. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_packaging.py +0 -0
  134. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_pm_client.py +0 -0
  135. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_prod_run_fixes.py +0 -0
  136. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_profile_pat_only.py +0 -0
  137. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_quiet_flag.py +0 -0
  138. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_recipient_commands.py +0 -0
  139. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_report_preview_commands.py +0 -0
  140. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_secret_files.py +0 -0
  141. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_slides_commands.py +0 -0
  142. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_tableau_commands.py +0 -0
  143. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_token_auth.py +0 -0
  144. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_token_commands.py +0 -0
  145. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_types.py +0 -0
  146. {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/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.1.1
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.1.1"
@@ -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 (
@@ -56,6 +58,15 @@ class RootGroup(AliasedGroup):
56
58
  def invoke(self, ctx: click.Context):
57
59
  try:
58
60
  return super().invoke(ctx)
61
+ except NotAuthenticatedError as exc:
62
+ # A login made under another config home lives in another keyring
63
+ # slot (security S9); say which home this command used, so the
64
+ # mismatch is diagnosable. The default home needs no explanation.
65
+ if exc.message.startswith("Not logged in.") and not pm_config.is_default_config_home():
66
+ raise click.ClickException(
67
+ f"{exc.message}\nConfig home: {pm_config.describe_config_home()}."
68
+ )
69
+ raise
59
70
  except PMApiError as exc:
60
71
  raise click.ClickException(
61
72
  describe_api_error(exc, group=ctx.invoked_subcommand)
@@ -64,7 +75,7 @@ class RootGroup(AliasedGroup):
64
75
 
65
76
  _GETTING_STARTED = """\b
66
77
  Get started with a personal access token:
67
- 1. Create a token in the PushMetrics web app under Access Tokens.
78
+ 1. Create a token in the web app under Settings > CLI & API.
68
79
  2. pm login --token paste the token at the prompt (stored securely)
69
80
  3. pm whoami then: pm report list, pm run list, pm report run <uid>
70
81
  """
@@ -182,13 +193,18 @@ def config_list(ctx: click.Context) -> None:
182
193
  ("PM_API_URL", os.environ.get("PM_API_URL")),
183
194
  ("PM_AUTH0_DOMAIN", os.environ.get("PM_AUTH0_DOMAIN")),
184
195
  ("PM_AUTH0_CLIENT_ID", os.environ.get("PM_AUTH0_CLIENT_ID")),
196
+ ("PM_CONFIG_HOME", os.environ.get("PM_CONFIG_HOME")),
185
197
  )
186
198
  if value
187
199
  }
188
200
 
189
201
  if ctx.obj["json"]:
190
202
  emit(
191
- {"profiles": rows, "env_overrides": env_overrides},
203
+ {
204
+ "profiles": rows,
205
+ "env_overrides": env_overrides,
206
+ "config_home": str(pm_config.CONFIG_DIR),
207
+ },
192
208
  json_output=True,
193
209
  )
194
210
  return
@@ -205,6 +221,11 @@ def config_list(ctx: click.Context) -> None:
205
221
  ],
206
222
  )
207
223
 
224
+ # Which config home is active decides which stored credentials (keyring
225
+ # slot) commands can see (security S9).
226
+ click.echo("")
227
+ click.echo(f"Config home: {pm_config.describe_config_home()}")
228
+
208
229
  if env_overrides:
209
230
  click.echo("")
210
231
  click.echo("Active env-var overrides (apply to all profiles):")
@@ -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
 
@@ -187,7 +187,7 @@ _GROUP_PAGES: Dict[str, Dict] = {
187
187
  "schema": "Available measures/dimensions. <uid>.",
188
188
  },
189
189
  "body_fields": (
190
- " --measure NAME repeatable, ≥1 required\n"
190
+ " --measure NAME repeatable, 1 or more required\n"
191
191
  " --dimension NAME repeatable\n"
192
192
  " --time-range last_30d | 2026-01-01:2026-04-01\n"
193
193
  " --limit N, --offset N, --no-cache\n"
@@ -460,7 +460,7 @@ _PLAIN_PAGES: Dict[str, str] = {
460
460
  " pm login --token paste the token at the hidden prompt\n"
461
461
  " <secrets-cmd> | pm login --token pipe it from a secrets manager\n"
462
462
  " PM_API_TOKEN=... use it without storing (CI, scripts)\n\n"
463
- "Tokens are created in the web app under Access Tokens."
463
+ "Tokens are created in the web app under Settings > CLI & API."
464
464
  ),
465
465
  "logout": (
466
466
  "pm logout — Forget the stored token or session for the profile.\n\n"
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
  import hashlib
14
14
  import os
15
15
  import sys
16
+ import tempfile
16
17
  from dataclasses import dataclass
17
18
  from pathlib import Path
18
19
  from typing import Any, Dict, Optional
@@ -109,6 +110,51 @@ def _ensure_dir() -> None:
109
110
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
110
111
 
111
112
 
113
+ def _atomic_write_text(path: Path, text: str) -> None:
114
+ """Write ``text`` to ``path`` atomically.
115
+
116
+ A direct ``path.write_text`` truncates the file first, so an interrupted
117
+ write (crash, Ctrl-C, full disk) or two concurrent ``pm config set`` runs
118
+ leaves a torn config.toml that the next command cannot parse. Instead
119
+ write a temp file in the same directory, flush+fsync it, then
120
+ ``os.replace`` it over the target -- an atomic rename on the same
121
+ filesystem -- so a reader sees either the old file or the whole new one,
122
+ never a half-written one. The existing file's mode is preserved (the
123
+ fresh temp file is created 0600 by mkstemp regardless, so a brand-new
124
+ config.toml is 0600).
125
+
126
+ If ``path`` is a symlink (dotfiles setups often link ~/.pm/config.toml
127
+ into a tracked dir), ``os.replace`` would otherwise swap the link itself
128
+ for a regular file. Resolve to the link target first and write there, so
129
+ the link is kept and its destination filesystem is the one the temp file
130
+ lands on.
131
+ """
132
+ target = path.resolve()
133
+ directory = target.parent
134
+ mode: Optional[int] = None
135
+ try:
136
+ mode = os.stat(target).st_mode & 0o777
137
+ except FileNotFoundError:
138
+ mode = None
139
+
140
+ fd, tmp_name = tempfile.mkstemp(dir=directory, prefix=".config.", suffix=".toml.tmp")
141
+ try:
142
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
143
+ f.write(text)
144
+ f.flush()
145
+ os.fsync(f.fileno())
146
+ if mode is not None:
147
+ os.chmod(tmp_name, mode)
148
+ os.replace(tmp_name, target)
149
+ except BaseException:
150
+ # Leave the existing config.toml untouched and clean up the temp file.
151
+ try:
152
+ os.unlink(tmp_name)
153
+ except FileNotFoundError:
154
+ pass
155
+ raise
156
+
157
+
112
158
  def _serialize_toml(data: Dict[str, Dict[str, Any]]) -> str:
113
159
  """Tiny TOML writer for our flat profile schema. Avoids pulling in tomli_w."""
114
160
  lines = []
@@ -132,12 +178,26 @@ def load_all() -> Dict[str, Dict[str, Any]]:
132
178
  if not CONFIG_FILE.exists():
133
179
  return {}
134
180
  with CONFIG_FILE.open("rb") as f:
135
- return tomllib.load(f)
181
+ raw = f.read()
182
+ try:
183
+ return tomllib.loads(raw.decode("utf-8"))
184
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
185
+ # A partially-written or corrupted config.toml must not take down
186
+ # every `pm` command with a raw traceback. load_all() runs at the
187
+ # start of almost all commands (get_profile -> load_all), so name the
188
+ # file and the one-line recovery (built-in profiles still work) in a
189
+ # clean error instead. save_all writes atomically, so an unparsable
190
+ # file now only comes from an external edit or a pre-fix torn write.
191
+ raise click.ClickException(
192
+ f"Config file {CONFIG_FILE} is not valid TOML ({exc}). "
193
+ f"Fix or remove it (e.g. `rm {CONFIG_FILE}`) and re-run; "
194
+ f"built-in profiles work without it."
195
+ ) from exc
136
196
 
137
197
 
138
198
  def save_all(data: Dict[str, Dict[str, Any]]) -> None:
139
199
  _ensure_dir()
140
- CONFIG_FILE.write_text(_serialize_toml(data), encoding="utf-8")
200
+ _atomic_write_text(CONFIG_FILE, _serialize_toml(data))
141
201
 
142
202
 
143
203
  def get_profile(name: str) -> Profile:
@@ -148,6 +148,11 @@ def _hint_for_status(status: int, group: str | None) -> str | None:
148
148
  f"Nothing with that id is visible in the active workspace. "
149
149
  f"{listing} shows what exists."
150
150
  )
151
+ if status == 402:
152
+ return (
153
+ "pm needs an active PushMetrics subscription for this workspace's "
154
+ "organization. Start a trial or upgrade in the web app."
155
+ )
151
156
  if status == 429:
152
157
  return "Too many requests. Wait a minute and retry."
153
158
  if status >= 500:
@@ -166,6 +171,9 @@ _BARE_STATUS_MESSAGES = frozenset({
166
171
  # The one textual 403 reachable from pm (non-member workspace switch):
167
172
  # a fact with no next action, so the hint follows it.
168
173
  "you don't belong to this workspace",
174
+ # The legacy 402 from subscription-gated endpoints (kept server-side for
175
+ # old clients): states the fact, names no action.
176
+ "no subscription found for this organization",
169
177
  })
170
178
 
171
179
 
@@ -224,5 +232,6 @@ def describe_api_error(exc: Exception, *, group: str | None = None) -> str:
224
232
  else:
225
233
  first_line = str(exc)
226
234
  explained = bool(message) and not _is_bare_status_message(message)
227
- hint = None if (explained and status == 403) else _hint_for_status(status, group)
235
+ # A 402/403 whose server sentence explains itself needs no generic hint.
236
+ hint = None if (explained and status in (402, 403)) else _hint_for_status(status, group)
228
237
  return f"{first_line}\nHint: {hint}" if hint else first_line
@@ -0,0 +1,67 @@
1
+ """The ``User-Agent`` the pm CLI sends on every request.
2
+
3
+ Why this exists: the server records the HTTP User-Agent on each
4
+ ``pat_exchanged`` audit row (``security_event.user_agent``), and the ops
5
+ Access Tokens page shows it. A bare ``requests.Session`` sends
6
+ ``python-requests/<ver>``, which cannot tell the pm CLI apart from any
7
+ other Python client, nor say WHICH tool is driving pm — Claude Code,
8
+ Copilot, Cursor, CI, or a human at a shell.
9
+
10
+ pm is driven *by* an AI coding agent as shell commands; the agent is
11
+ upstream of the process and leaves no wire signal of its own. So the only
12
+ way the server can learn the calling tool is for pm to declare it. The
13
+ tool sets ``PM_CLIENT`` (e.g. ``claude-code``, ``copilot``, ``cursor``,
14
+ ``ci``); unset means a plain CLI invocation.
15
+
16
+ Shape (stable, parseable, no PII):
17
+
18
+ pm/0.1.0 (client=claude-code; py/3.11.6)
19
+
20
+ ``client`` is sanitised to ``[a-z0-9._-]`` and capped, so a hostile or
21
+ accidental value can neither inject into the header nor blow the server's
22
+ 512-char column. The value carries no secret — it is a tool name only.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import platform
28
+ import re
29
+
30
+ from pm import __version__
31
+
32
+ CLIENT_ENV_VAR = "PM_CLIENT"
33
+
34
+ # Header-safe, low-cardinality, no PII. Anything else is dropped to "cli".
35
+ _CLIENT_RE = re.compile(r"[^a-z0-9._-]+")
36
+ _MAX_CLIENT_LEN = 40
37
+ _DEFAULT_CLIENT = "cli"
38
+
39
+
40
+ def client_name() -> str:
41
+ """The calling tool, from ``PM_CLIENT``, normalised. ``"cli"`` when unset.
42
+
43
+ Lowercased, non-``[a-z0-9._-]`` runs collapsed to ``-``, trimmed and
44
+ capped. An empty or all-invalid value falls back to ``"cli"`` rather
45
+ than emitting a blank ``client=``.
46
+ """
47
+ raw = (os.environ.get(CLIENT_ENV_VAR) or "").strip().lower()
48
+ if not raw:
49
+ return _DEFAULT_CLIENT
50
+ cleaned = _CLIENT_RE.sub("-", raw).strip("-")[:_MAX_CLIENT_LEN]
51
+ return cleaned or _DEFAULT_CLIENT
52
+
53
+
54
+ def _python_version() -> str:
55
+ try:
56
+ return platform.python_version()
57
+ except Exception: # noqa: BLE001 - never let UA construction fail a request
58
+ return "unknown"
59
+
60
+
61
+ def user_agent() -> str:
62
+ """The full ``User-Agent`` value for this process.
63
+
64
+ e.g. ``pm/0.1.0 (client=claude-code; py/3.11.6)``. Pure function of the
65
+ version and ``PM_CLIENT``; safe to call per request.
66
+ """
67
+ return f"pm/{__version__} (client={client_name()}; py/{_python_version()})"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pushmetrics-cli
3
- Version: 0.1.0
3
+ Version: 0.1.1
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
 
@@ -10,6 +10,7 @@ pm/errors.py
10
10
  pm/log.py
11
11
  pm/output.py
12
12
  pm/types.py
13
+ pm/user_agent.py
13
14
  pm/validators.py
14
15
  pm/auth/__init__.py
15
16
  pm/auth/device_flow.py
@@ -107,14 +108,17 @@ tests/test_client_saved_blocks.py
107
108
  tests/test_client_tableau.py
108
109
  tests/test_client_users.py
109
110
  tests/test_client_webhooks.py
111
+ tests/test_config_atomic.py
110
112
  tests/test_credential_safety.py
111
113
  tests/test_device_flow.py
112
114
  tests/test_did_you_mean.py
113
115
  tests/test_domain_groups.py
116
+ tests/test_e2_no_domain.py
114
117
  tests/test_errors.py
115
118
  tests/test_first_run.py
116
119
  tests/test_hardening.py
117
120
  tests/test_help_command.py
121
+ tests/test_help_encoding.py
118
122
  tests/test_integration_commands.py
119
123
  tests/test_jwt_cache.py
120
124
  tests/test_keyring_namespace.py
@@ -135,4 +139,5 @@ tests/test_tableau_commands.py
135
139
  tests/test_token_auth.py
136
140
  tests/test_token_commands.py
137
141
  tests/test_types.py
142
+ tests/test_user_agent.py
138
143
  tests/test_validators.py