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.
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/PKG-INFO +1 -1
- pushmetrics_cli-0.2.0/pm/__init__.py +1 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/cli.py +13 -1
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/__init__.py +4 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/blocks.py +1 -0
- pushmetrics_cli-0.2.0/pm/client/deliverability.py +37 -0
- pushmetrics_cli-0.2.0/pm/client/schedules.py +49 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/_helpers.py +17 -1
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/auth.py +3 -3
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/db.py +7 -7
- pushmetrics_cli-0.2.0/pm/commands/deliverability.py +182 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/help.py +28 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/reports.py +6 -5
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/roles.py +3 -3
- pushmetrics_cli-0.2.0/pm/commands/schedule.py +313 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/users.py +2 -2
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/errors.py +15 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/output.py +28 -2
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/PKG-INFO +1 -1
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/SOURCES.txt +10 -0
- pushmetrics_cli-0.2.0/tests/test_api_line_keeps_lines.py +75 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_blocks.py +45 -0
- pushmetrics_cli-0.2.0/tests/test_deliverability_commands.py +225 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_first_run.py +48 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_help_command.py +7 -7
- pushmetrics_cli-0.2.0/tests/test_multiline_control_chars.py +173 -0
- pushmetrics_cli-0.2.0/tests/test_output_control_chars.py +47 -0
- pushmetrics_cli-0.2.0/tests/test_schedule_commands.py +432 -0
- pushmetrics_cli-0.2.0/tests/test_server_names_sanitised.py +75 -0
- pushmetrics_cli-0.1.1/pm/__init__.py +0 -1
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/MANIFEST.in +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/README.md +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/__main__.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/_alias.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/__init__.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/device_flow.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/exchange.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/jwt_cache.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/secret_files.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/session.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/store.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/auth/token.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/access_tokens.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/base.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/databases.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/favorites.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/folders.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/integrations.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/organizations.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/recipients.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/reports.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/roles.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/runs.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/saved_blocks.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/tableau.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/users.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/webhooks.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/client/workspaces.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/__init__.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/_block_factory.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/block.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/chart.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/email.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/explore.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/favorites.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/folders.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/integrations.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/metrics_view.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/organizations.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/recipient.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/runs.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/saved_blocks.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/sftp.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/slack.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/slides.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/sql.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/tableau.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/token.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/webhooks.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/commands/workspace.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/config.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/log.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/__init__.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/access_tokens.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/blocks.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/databases.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/favorites.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/folders.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/integrations.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/organizations.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/recipients.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/reports.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/roles.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/runs.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/saved_blocks.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/users.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/webhooks.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/models/workspaces.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/types.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/user_agent.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pm/validators.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/dependency_links.txt +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/entry_points.txt +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/requires.txt +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pushmetrics_cli.egg-info/top_level.txt +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/pyproject.toml +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/setup.cfg +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/__init__.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/_runner.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/conftest.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_agent_profile.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_aliases.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_block_factory.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_access_tokens.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_favorites.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_folders.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_integrations.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_organizations.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_recipients.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_reports.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_runs.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_saved_blocks.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_tableau.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_users.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_client_webhooks.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_config_atomic.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_credential_safety.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_device_flow.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_did_you_mean.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_domain_groups.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_e2_no_domain.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_errors.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_hardening.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_help_encoding.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_integration_commands.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_jwt_cache.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_keyring_namespace.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_list_limit.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_metrics_view_commands.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_no_backend_imports.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_packaging.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_pm_client.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_prod_run_fixes.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_profile_pat_only.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_quiet_flag.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_recipient_commands.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_report_preview_commands.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_secret_files.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_session.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_slides_commands.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_tableau_commands.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_token_auth.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_token_commands.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_types.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_user_agent.py +0 -0
- {pushmetrics_cli-0.1.1 → pushmetrics_cli-0.2.0}/tests/test_validators.py +0 -0
|
@@ -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
|
-
|
|
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)
|
|
@@ -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)
|