pushmetrics-cli 0.1.1__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.1 → pushmetrics_cli-0.2.0}/PKG-INFO +1 -1
  2. pushmetrics_cli-0.2.0/pm/__init__.py +1 -0
  3. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/cli.py +13 -1
  4. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/__init__.py +4 -0
  5. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/blocks.py +1 -0
  6. pushmetrics_cli-0.2.0/pm/client/deliverability.py +37 -0
  7. pushmetrics_cli-0.2.0/pm/client/schedules.py +49 -0
  8. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/_helpers.py +17 -1
  9. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/auth.py +3 -3
  10. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/db.py +7 -7
  11. pushmetrics_cli-0.2.0/pm/commands/deliverability.py +182 -0
  12. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/help.py +28 -0
  13. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/reports.py +6 -5
  14. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/roles.py +3 -3
  15. pushmetrics_cli-0.2.0/pm/commands/schedule.py +313 -0
  16. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/users.py +2 -2
  17. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/errors.py +15 -0
  18. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/output.py +28 -2
  19. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/PKG-INFO +1 -1
  20. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/SOURCES.txt +10 -0
  21. pushmetrics_cli-0.2.0/tests/test_api_line_keeps_lines.py +75 -0
  22. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_blocks.py +45 -0
  23. pushmetrics_cli-0.2.0/tests/test_deliverability_commands.py +225 -0
  24. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_first_run.py +48 -0
  25. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_help_command.py +7 -7
  26. pushmetrics_cli-0.2.0/tests/test_multiline_control_chars.py +173 -0
  27. pushmetrics_cli-0.2.0/tests/test_output_control_chars.py +47 -0
  28. pushmetrics_cli-0.2.0/tests/test_schedule_commands.py +432 -0
  29. pushmetrics_cli-0.2.0/tests/test_server_names_sanitised.py +75 -0
  30. pushmetrics_cli-0.1.1/pm/__init__.py +0 -1
  31. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/MANIFEST.in +0 -0
  32. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/README.md +0 -0
  33. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/__main__.py +0 -0
  34. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/_alias.py +0 -0
  35. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/__init__.py +0 -0
  36. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/device_flow.py +0 -0
  37. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/exchange.py +0 -0
  38. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/jwt_cache.py +0 -0
  39. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/secret_files.py +0 -0
  40. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/session.py +0 -0
  41. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/store.py +0 -0
  42. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/token.py +0 -0
  43. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/access_tokens.py +0 -0
  44. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/base.py +0 -0
  45. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/databases.py +0 -0
  46. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/favorites.py +0 -0
  47. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/folders.py +0 -0
  48. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/integrations.py +0 -0
  49. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/organizations.py +0 -0
  50. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/recipients.py +0 -0
  51. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/reports.py +0 -0
  52. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/roles.py +0 -0
  53. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/runs.py +0 -0
  54. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/saved_blocks.py +0 -0
  55. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/tableau.py +0 -0
  56. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/users.py +0 -0
  57. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/webhooks.py +0 -0
  58. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/workspaces.py +0 -0
  59. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/__init__.py +0 -0
  60. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/_block_factory.py +0 -0
  61. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/block.py +0 -0
  62. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/chart.py +0 -0
  63. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/email.py +0 -0
  64. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/explore.py +0 -0
  65. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/favorites.py +0 -0
  66. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/folders.py +0 -0
  67. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/integrations.py +0 -0
  68. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/metrics_view.py +0 -0
  69. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/organizations.py +0 -0
  70. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/recipient.py +0 -0
  71. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/runs.py +0 -0
  72. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/saved_blocks.py +0 -0
  73. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/sftp.py +0 -0
  74. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/slack.py +0 -0
  75. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/slides.py +0 -0
  76. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/sql.py +0 -0
  77. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/tableau.py +0 -0
  78. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/token.py +0 -0
  79. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/webhooks.py +0 -0
  80. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/workspace.py +0 -0
  81. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/config.py +0 -0
  82. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/log.py +0 -0
  83. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/__init__.py +0 -0
  84. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/access_tokens.py +0 -0
  85. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/blocks.py +0 -0
  86. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/databases.py +0 -0
  87. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/favorites.py +0 -0
  88. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/folders.py +0 -0
  89. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/integrations.py +0 -0
  90. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/organizations.py +0 -0
  91. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/recipients.py +0 -0
  92. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/reports.py +0 -0
  93. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/roles.py +0 -0
  94. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/runs.py +0 -0
  95. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/saved_blocks.py +0 -0
  96. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/users.py +0 -0
  97. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/webhooks.py +0 -0
  98. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/workspaces.py +0 -0
  99. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/types.py +0 -0
  100. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/user_agent.py +0 -0
  101. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/validators.py +0 -0
  102. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/dependency_links.txt +0 -0
  103. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/entry_points.txt +0 -0
  104. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/requires.txt +0 -0
  105. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/top_level.txt +0 -0
  106. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pyproject.toml +0 -0
  107. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/setup.cfg +0 -0
  108. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/__init__.py +0 -0
  109. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/_runner.py +0 -0
  110. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/conftest.py +0 -0
  111. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_agent_profile.py +0 -0
  112. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_aliases.py +0 -0
  113. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_block_factory.py +0 -0
  114. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_access_tokens.py +0 -0
  115. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_favorites.py +0 -0
  116. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_folders.py +0 -0
  117. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_integrations.py +0 -0
  118. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_organizations.py +0 -0
  119. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_recipients.py +0 -0
  120. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_reports.py +0 -0
  121. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_runs.py +0 -0
  122. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_saved_blocks.py +0 -0
  123. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_tableau.py +0 -0
  124. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_users.py +0 -0
  125. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_webhooks.py +0 -0
  126. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_config_atomic.py +0 -0
  127. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_credential_safety.py +0 -0
  128. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_device_flow.py +0 -0
  129. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_did_you_mean.py +0 -0
  130. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_domain_groups.py +0 -0
  131. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_e2_no_domain.py +0 -0
  132. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_errors.py +0 -0
  133. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_hardening.py +0 -0
  134. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_help_encoding.py +0 -0
  135. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_integration_commands.py +0 -0
  136. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_jwt_cache.py +0 -0
  137. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_keyring_namespace.py +0 -0
  138. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_list_limit.py +0 -0
  139. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_metrics_view_commands.py +0 -0
  140. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_no_backend_imports.py +0 -0
  141. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_packaging.py +0 -0
  142. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_pm_client.py +0 -0
  143. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_prod_run_fixes.py +0 -0
  144. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_profile_pat_only.py +0 -0
  145. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_quiet_flag.py +0 -0
  146. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_recipient_commands.py +0 -0
  147. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_report_preview_commands.py +0 -0
  148. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_secret_files.py +0 -0
  149. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_session.py +0 -0
  150. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_slides_commands.py +0 -0
  151. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_tableau_commands.py +0 -0
  152. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_token_auth.py +0 -0
  153. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_token_commands.py +0 -0
  154. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_types.py +0 -0
  155. {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_user_agent.py +0 -0
  156. {pushmetrics_cli-0.1.1 → 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.1
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
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -24,6 +24,7 @@ from pm.config import (
24
24
  )
25
25
  from pm.errors import describe_api_error
26
26
  from pm.output import emit
27
+ from pm.user_agent import CLIENT_ENV_VAR, client_name
27
28
 
28
29
  _cli_logger = logging.getLogger("pm.cli")
29
30
 
@@ -194,6 +195,9 @@ def config_list(ctx: click.Context) -> None:
194
195
  ("PM_AUTH0_DOMAIN", os.environ.get("PM_AUTH0_DOMAIN")),
195
196
  ("PM_AUTH0_CLIENT_ID", os.environ.get("PM_AUTH0_CLIENT_ID")),
196
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)),
197
201
  )
198
202
  if value
199
203
  }
@@ -230,7 +234,11 @@ def config_list(ctx: click.Context) -> None:
230
234
  click.echo("")
231
235
  click.echo("Active env-var overrides (apply to all profiles):")
232
236
  for var, value in env_overrides.items():
233
- 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)
234
242
 
235
243
 
236
244
  from pm.commands.auth import login, logout, whoami
@@ -246,6 +254,8 @@ from pm.commands.integrations import integrations as integrations_group
246
254
  from pm.commands.metrics_view import metrics_view as metrics_view_group
247
255
  from pm.commands.organizations import organizations as organizations_group
248
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
249
259
  from pm.commands.reports import reports as reports_group
250
260
  from pm.commands.roles import roles as roles_group
251
261
  from pm.commands.runs import runs as runs_group
@@ -284,6 +294,8 @@ cli.add_command(webhooks_group, name="webhook")
284
294
  cli.add_command(favorites_group, name="favorite")
285
295
  cli.add_command(organizations_group, name="organization")
286
296
  cli.add_command(recipient_group)
297
+ cli.add_command(deliverability_group)
298
+ cli.add_command(schedule_group)
287
299
  cli.add_command(roles_group, name="role")
288
300
  cli.add_command(saved_blocks_group, name="saved-block")
289
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:
@@ -122,9 +122,9 @@ def login(ctx: click.Context, no_browser: bool, use_token: bool) -> None:
122
122
  info(ctx, "")
123
123
  info(
124
124
  ctx,
125
- 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}"),
126
126
  )
127
- 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}"))
128
128
  info(ctx, "Waiting for authorization...")
129
129
 
130
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:
@@ -0,0 +1,182 @@
1
+ """`pm deliverability`: email addresses Amazon SES blocks for this workspace.
2
+
3
+ A thin wrapper over the Recipient API's deliverability routes
4
+ (``superset/pushmetrics/views/recipient/api.py``, ``/recipient/deliverability``,
5
+ ``/deliverability/events``, ``/deliverability/unblock``). Labels mirror the web
6
+ app's (``frontend/src/shared/services/emailDeliverabilityApi.ts``) so both say
7
+ the same thing about the same address.
8
+
9
+ Verbs: list, events EMAIL, unblock EMAIL. ``unblock`` is admin-only; the
10
+ server enforces that (a 403 for anyone else), the CLI only words the refusal.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Dict, List
15
+
16
+ import click
17
+
18
+ from pm._alias import AliasedGroup
19
+ from pm.client import PMClient
20
+ from pm.client.base import PMApiError
21
+ from pm.commands._helpers import info, json_option, yes_option
22
+ from pm.commands._helpers import iso_seconds as _iso
23
+ from pm.commands._helpers import stdin_is_terminal as _stdin_is_terminal
24
+ from pm.errors import describe_api_error
25
+ from pm.output import emit
26
+
27
+ # Same words as the web app (emailDeliverabilityApi.ts / EmailDeliverability.tsx).
28
+ REASON_LABEL = {"BOUNCE": "Bounced", "COMPLAINT": "Marked as spam"}
29
+ EVENT_LABEL = {
30
+ "bounce": "Bounced",
31
+ "complaint": "Marked as spam",
32
+ "suppressed_drop": "Email dropped (address blocked)",
33
+ "suppressed": "Blocked by the email provider",
34
+ "cleared": "Unblocked",
35
+ }
36
+ SOURCE_LABEL = {
37
+ "manual": "by an admin",
38
+ "invite": "by inviting the user",
39
+ "recipient_edit": "by saving the recipient",
40
+ "retry": "by the automatic retry",
41
+ "ses_list": "in the email provider",
42
+ "ses_event": "by the email provider",
43
+ }
44
+ USED_AS_LABEL = {"recipient": "recipient", "user": "user", "block": "email block"}
45
+
46
+ NOT_CONFIGURED = "Email deliverability is not configured on this deployment."
47
+ EMAIL_HELP = "Email address this workspace sends to."
48
+
49
+
50
+ def _api_failure(exc: PMApiError, hint: str) -> click.ClickException:
51
+ """The API line as describe_api_error writes it (``failed: NNN`` kept,
52
+ redacted), with this command's own next action as the hint."""
53
+ first_line = describe_api_error(exc, group="deliverability").split("\n", 1)[0]
54
+ return click.ClickException(f"{first_line}\nHint: {hint}")
55
+
56
+
57
+ _NOT_IN_WORKSPACE = (
58
+ "That address is not one this workspace sends to (a recipient, user or "
59
+ "email block). `pm deliverability list` shows the blocked ones."
60
+ )
61
+
62
+
63
+ @click.group(name="deliverability", cls=AliasedGroup, aliases={"ls": "list"})
64
+ def deliverability() -> None:
65
+ """Email deliverability: addresses the email provider blocks.
66
+
67
+ Amazon SES stops sending to an address after a hard bounce or a spam
68
+ complaint. These commands show which addresses this workspace sends to
69
+ are blocked, why, and let an admin unblock one.
70
+ """
71
+
72
+
73
+ _LIST_COLUMNS = [
74
+ ("EMAIL", "email"),
75
+ ("STATUS", "status"),
76
+ ("REASON", lambda r: REASON_LABEL.get(r.get("reason") or "", "Blocked")),
77
+ ("BLOCKED_SINCE", lambda r: _iso(r.get("blocked_since"))),
78
+ ("USED_AS", lambda r: ", ".join(USED_AS_LABEL.get(u, u) for u in r.get("used_as") or [])),
79
+ ("REPORTS", lambda r: ", ".join(rep.get("name") or rep.get("uid") or "" for rep in r.get("reports") or [])),
80
+ ]
81
+
82
+
83
+ @deliverability.command(name="list")
84
+ @json_option
85
+ @click.pass_context
86
+ def list_cmd(ctx: click.Context) -> None:
87
+ """List the addresses this workspace sends to that are blocked.
88
+
89
+ Each row says why (bounced or marked as spam), since when, where the
90
+ address is used and which reports send to it. --json prints the full rows.
91
+ Fails if email deliverability is not configured on this deployment.
92
+ """
93
+ client = PMClient.from_profile(ctx.obj["profile"])
94
+ body = client.deliverability.list()
95
+ client.persist()
96
+ rows: List[Dict[str, Any]] = body.get("result") or []
97
+ if body.get("enabled") is False:
98
+ # Nothing is tracked: the command cannot answer, so it fails (exit 1,
99
+ # also under --quiet and --json). An empty table or [] would wrongly
100
+ # read as "nothing blocked". Same state as unblock's 503.
101
+ raise click.ClickException(NOT_CONFIGURED)
102
+ emit(rows, json_output=ctx.obj["json"], columns=_LIST_COLUMNS)
103
+
104
+
105
+ def _event_label(event: Dict[str, Any]) -> str:
106
+ label = EVENT_LABEL.get(event.get("event_type") or "", event.get("event_type") or "")
107
+ if event.get("event_type") == "cleared" and event.get("source") in SOURCE_LABEL:
108
+ label = f"{label} {SOURCE_LABEL[event['source']]}"
109
+ return label
110
+
111
+
112
+ _EVENT_COLUMNS = [
113
+ ("WHEN", lambda e: _iso(e.get("occurred_at"))),
114
+ ("EVENT", _event_label),
115
+ ("DETAIL", lambda e: e.get("diagnostic") or e.get("sub_type") or ""),
116
+ ]
117
+
118
+
119
+ @deliverability.command(name="events")
120
+ @click.argument("email", metavar="EMAIL")
121
+ @json_option
122
+ @click.pass_context
123
+ def events(ctx: click.Context, email: str) -> None:
124
+ """Show the bounce, complaint and unblock history of one address.
125
+
126
+ Newest event last, up to the latest 20.
127
+
128
+ EMAIL: Email address this workspace sends to.
129
+ """
130
+ client = PMClient.from_profile(ctx.obj["profile"])
131
+ try:
132
+ body = client.deliverability.events(email)
133
+ except PMApiError as exc:
134
+ if exc.status == 404:
135
+ raise _api_failure(exc, _NOT_IN_WORKSPACE)
136
+ raise
137
+ client.persist()
138
+ # The server sends newest first; a log reads oldest to newest.
139
+ rows = list(reversed(body.get("result") or []))
140
+ emit(rows, json_output=ctx.obj["json"], columns=_EVENT_COLUMNS)
141
+
142
+
143
+ @deliverability.command(name="unblock")
144
+ @click.argument("email", metavar="EMAIL")
145
+ @yes_option
146
+ @json_option
147
+ @click.pass_context
148
+ def unblock(ctx: click.Context, email: str, yes: bool) -> None:
149
+ """Unblock an address so this workspace's email reaches it again.
150
+
151
+ Admins only. The address gets mail again from the next send; if it still
152
+ bounces or the person marks a message as spam, the email provider blocks
153
+ it again.
154
+
155
+ EMAIL: Email address this workspace sends to.
156
+ """
157
+ if not yes:
158
+ # Checked before the prompt, so a piped "y" cannot pass for a person.
159
+ if not _stdin_is_terminal():
160
+ raise click.ClickException(
161
+ "Not unblocked: pm deliverability unblock needs --yes when stdin is not a terminal."
162
+ )
163
+ click.confirm(f"Unblock {email}?", abort=True)
164
+ client = PMClient.from_profile(ctx.obj["profile"])
165
+ try:
166
+ body = client.deliverability.unblock(email)
167
+ except PMApiError as exc:
168
+ if exc.status == 403:
169
+ raise _api_failure(exc, "Unblocking an address needs an admin role in this workspace.")
170
+ if exc.status == 404:
171
+ raise _api_failure(exc, _NOT_IN_WORKSPACE)
172
+ if exc.status == 503:
173
+ # The server's sentence is the whole story ("Email deliverability
174
+ # is not configured on this deployment."); the generic 5xx hint
175
+ # ("retry in a minute") would be wrong, retrying cannot help.
176
+ raise click.ClickException(describe_api_error(exc, group="deliverability").split("\n", 1)[0])
177
+ raise
178
+ client.persist()
179
+ if ctx.obj["json"]:
180
+ emit(body.get("result") or {"email": email}, json_output=True)
181
+ return
182
+ info(ctx, f"Unblocked {email}. The next email to it will be sent.")
@@ -373,6 +373,34 @@ _GROUP_PAGES: Dict[str, Dict] = {
373
373
  ),
374
374
  "summary": "Recipients (delivery-target CRM).",
375
375
  },
376
+ "schedule": {
377
+ "verbs": {
378
+ "list": "Schedules. [--report <uid>].",
379
+ "get": "Show one. <uid>.",
380
+ "create": "--report <uid> --rrule '<DTSTART RRULE>' [--name <n>]. Prints uid.",
381
+ "update": "Change the rule. <uid> --rrule '<DTSTART RRULE>'.",
382
+ "run": "Run now; delivers to all its destinations. <uid> --yes (needed off a terminal).",
383
+ "pause": "Stop it running. <uid>.",
384
+ "resume": "Start it again. <uid>.",
385
+ },
386
+ "example": (
387
+ " pm schedule list --report <report_uid> --json\n"
388
+ " pm schedule pause <uid>"
389
+ ),
390
+ "summary": "Schedules: when a report runs on its own.",
391
+ },
392
+ "deliverability": {
393
+ "verbs": {
394
+ "list": "Blocked addresses this workspace sends to, with reason and reports.",
395
+ "events": "History of one address, newest last. <email>.",
396
+ "unblock": "Unblock an address (admins only). <email> [--yes].",
397
+ },
398
+ "example": (
399
+ " pm deliverability list --json\n"
400
+ " pm deliverability unblock alice@example.com --yes"
401
+ ),
402
+ "summary": "Email addresses the email provider blocks.",
403
+ },
376
404
  "integration": {
377
405
  "verbs": {
378
406
  "list": "Workspace integrations (Tableau / Slack / Email / SFTP / GCP / S3 / Slides etc.). [--name <text>] [--type <t>] [--limit <n>].",
@@ -22,7 +22,7 @@ from pm.commands._helpers import (
22
22
  name_filter_option,
23
23
  select_rows,
24
24
  )
25
- from pm.output import emit
25
+ from pm.output import _safe_lines, _safe_text, emit
26
26
 
27
27
 
28
28
  @click.group(name="reports", cls=AliasedGroup, aliases={"ls": "list"})
@@ -163,6 +163,7 @@ def yaml_(ctx: click.Context, uid: str) -> None:
163
163
  client = PMClient.from_profile(profile)
164
164
  yaml_text = client.reports.yaml(uid)
165
165
  client.persist()
166
+ yaml_text = _safe_lines(yaml_text)
166
167
  # Write directly to avoid emit's trailing newline (YAML already has one).
167
168
  sys.stdout.write(yaml_text)
168
169
  if not yaml_text.endswith("\n"):
@@ -285,21 +286,21 @@ def apply(
285
286
  return
286
287
  summary = result.get("summary") or {}
287
288
  for k, v in summary.items():
288
- click.echo(f" {k}: {v}")
289
+ click.echo(_safe_text(f" {k}: {v}"))
289
290
  diff = result.get("yaml_diff", "")
290
291
  if diff:
291
292
  click.echo("")
292
- click.echo(diff)
293
+ click.echo(_safe_lines(diff))
293
294
  return
294
295
 
295
296
  res_uid = result.get("uid", "")
296
297
  res_name = result.get("name", "")
297
- click.echo(f"{action}: {res_uid} {res_name}")
298
+ click.echo(_safe_text(f"{action}: {res_uid} {res_name}"))
298
299
  warn = result.get("warning_duplicate_uids")
299
300
  if warn:
300
301
  click.echo(
301
302
  f" warning: {len(warn)} existing report(s) with the same name in this workspace: "
302
- + ", ".join(warn)
303
+ + _safe_text(", ".join(str(w) for w in warn))
303
304
  + " -- use `pm report apply ... --uid <one>` to update instead."
304
305
  )
305
306
 
@@ -9,7 +9,7 @@ from pm._alias import AliasedGroup
9
9
  from pm.client import PMClient
10
10
  from pm.client.roles import MAIN_ROLE_NAMES
11
11
  from pm.commands._helpers import json_option, limit_option, name_filter_option, select_rows
12
- from pm.output import emit
12
+ from pm.output import _safe_text, emit
13
13
 
14
14
 
15
15
  @click.group(name="roles", cls=AliasedGroup, aliases={"ls": "list"})
@@ -74,7 +74,7 @@ def assign(ctx: click.Context, role_ref: str, user_uids: tuple) -> None:
74
74
  return
75
75
  role_info = result.get("role") if isinstance(result, dict) else None
76
76
  role_name = (role_info or {}).get("name", role_ref)
77
- click.echo(f"Assigned {len(user_uids)} user(s) to role {role_name!r}.", err=True)
77
+ click.echo(f"Assigned {len(user_uids)} user(s) to role {_safe_text(str(role_name))!r}.", err=True)
78
78
 
79
79
 
80
80
  @roles.command(name="revoke")
@@ -99,4 +99,4 @@ def revoke(ctx: click.Context, role_ref: str, user_uids: tuple) -> None:
99
99
  return
100
100
  role_info = result.get("role") if isinstance(result, dict) else None
101
101
  role_name = (role_info or {}).get("name", role_ref)
102
- click.echo(f"Removed {len(user_uids)} user(s) from role {role_name!r}.", err=True)
102
+ click.echo(f"Removed {len(user_uids)} user(s) from role {_safe_text(str(role_name))!r}.", err=True)