plugsync-cli 0.5.0__tar.gz → 0.7.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.
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/PKG-INFO +1 -1
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/__init__.py +1 -1
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/diff.py +38 -2
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/preview.py +12 -4
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/pull.py +7 -7
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/push.py +38 -5
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/context.py +71 -2
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/findings.py +13 -4
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/serializer.py +212 -31
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/PKG-INFO +1 -1
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/pyproject.toml +1 -1
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/tests/test_commands.py +453 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/tests/test_e2e_draft_api.py +109 -1
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/tests/test_hand_edited_yaml.py +1 -0
- plugsync_cli-0.7.0/tests/test_serializer.py +480 -0
- plugsync_cli-0.5.0/tests/test_serializer.py +0 -239
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/README.md +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/bundler.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/client.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/__init__.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/auth.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/connector.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/log.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/plugin.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/rollback.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/validate.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/compat.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/config.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/main.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli/schema_defaults.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/SOURCES.txt +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/requires.txt +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/top_level.txt +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/setup.cfg +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/tests/test_api_contract.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/tests/test_auth_commands.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/tests/test_client.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/tests/test_compat.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/tests/test_config.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/tests/test_connector_commands.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/tests/test_plugin_commands.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.7.0}/tests/test_schema_defaults.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plugsync-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required
|
|
5
5
|
Author-email: Exelab <hello@plugsync.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""PluSync CLI — git-like connector management."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.7.0"
|
|
@@ -51,15 +51,21 @@ from typing import Any
|
|
|
51
51
|
|
|
52
52
|
import click
|
|
53
53
|
from rich.console import Console
|
|
54
|
+
from rich.markup import escape
|
|
54
55
|
from rich.table import Table
|
|
55
56
|
|
|
56
57
|
from plugsync_cli.context import build_client, fail, load_context
|
|
57
58
|
from plugsync_cli.schema_defaults import SchemaNode, plugin_embedded_fields, schema_root
|
|
59
|
+
from plugsync_cli.serializer import OPTIONAL_COLLECTIONS
|
|
58
60
|
|
|
59
61
|
console = Console()
|
|
60
62
|
|
|
61
63
|
# Top-level config keys compared item-by-item (keyed by name) rather than whole.
|
|
62
|
-
|
|
64
|
+
# `sources` reads like the other two here: a published version from before the
|
|
65
|
+
# sources were versioned simply carries no key, so every local source shows up
|
|
66
|
+
# as added, which is what it is against that version. The local side always
|
|
67
|
+
# declares the key: `read_config_directory` refuses a directory that does not.
|
|
68
|
+
COLLECTIONS = {"entities": "entity", "flows": "flow", "sources": "source"}
|
|
63
69
|
|
|
64
70
|
# Keys listed in one Detail cell before the rest is summarized. A row exists to
|
|
65
71
|
# tell an operator where to look, and forty paths in a table cell do not.
|
|
@@ -198,6 +204,7 @@ def _diff_configs(local: dict, remote: dict, root: SchemaNode) -> list[dict]:
|
|
|
198
204
|
|
|
199
205
|
for key, label in COLLECTIONS.items():
|
|
200
206
|
member = root.child(key).item()
|
|
207
|
+
changes.extend(_declaration_change(key, local, remote))
|
|
201
208
|
local_items, remote_items = _by_name(local.get(key)), _by_name(remote.get(key))
|
|
202
209
|
for name in sorted(set(local_items) | set(remote_items)):
|
|
203
210
|
if name not in remote_items:
|
|
@@ -228,6 +235,30 @@ def _diff_configs(local: dict, remote: dict, root: SchemaNode) -> list[dict]:
|
|
|
228
235
|
return changes
|
|
229
236
|
|
|
230
237
|
|
|
238
|
+
def _declaration_change(key: str, local: dict, remote: dict) -> list[dict]:
|
|
239
|
+
"""The row for a collection the local files declare and the version does not.
|
|
240
|
+
|
|
241
|
+
Only an optional collection can be in that position, and only one way
|
|
242
|
+
round: the local directory always declares `sources` (a directory that does
|
|
243
|
+
not is refused before this runs), while a version published before the
|
|
244
|
+
sources were part of the config carries no key. Item by item "declared, and
|
|
245
|
+
empty" reads the same as that historical absence -- no sources either way
|
|
246
|
+
-- so without this row a directory with `sources: []` would diff clean
|
|
247
|
+
against a version that says nothing about sources.
|
|
248
|
+
"""
|
|
249
|
+
if key not in OPTIONAL_COLLECTIONS or remote.get(key) is not None:
|
|
250
|
+
return []
|
|
251
|
+
return [
|
|
252
|
+
_simple(
|
|
253
|
+
f"{key}_key_added",
|
|
254
|
+
key,
|
|
255
|
+
f"declared in your files, absent from the published version, which "
|
|
256
|
+
f"predates {key} being part of the config: publishing makes the "
|
|
257
|
+
f"config the authority for the connector's {key}",
|
|
258
|
+
)
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
|
|
231
262
|
def _simple(change_type: str, scope: str, detail: str) -> dict:
|
|
232
263
|
return {
|
|
233
264
|
"type": change_type,
|
|
@@ -264,7 +295,12 @@ def _table(changes: list[dict]) -> Table:
|
|
|
264
295
|
# name -- which is the part this column exists to show.
|
|
265
296
|
table.add_column("Detail", overflow="fold")
|
|
266
297
|
for change in changes:
|
|
267
|
-
|
|
298
|
+
# escape: item names and key paths come from the config, and one in
|
|
299
|
+
# brackets is valid rich markup that would be read as a style tag and
|
|
300
|
+
# swallowed, taking the name this column exists to show with it.
|
|
301
|
+
table.add_row(
|
|
302
|
+
_mark(change["type"]), escape(change["scope"]), escape(change["detail"])
|
|
303
|
+
)
|
|
268
304
|
return table
|
|
269
305
|
|
|
270
306
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""plugsync preview -- what publishing the draft would change."""
|
|
2
2
|
import click
|
|
3
3
|
from rich.console import Console
|
|
4
|
+
from rich.markup import escape
|
|
4
5
|
from rich.table import Table
|
|
5
6
|
|
|
6
7
|
from plugsync_cli.context import (
|
|
@@ -55,11 +56,18 @@ def preview(path: str | None):
|
|
|
55
56
|
console.print(f"\n[bold]Publishing would apply {len(changes)} change(s)[/bold]\n")
|
|
56
57
|
table = Table(show_header=True, header_style="bold")
|
|
57
58
|
table.add_column("Change", width=24)
|
|
58
|
-
|
|
59
|
+
# fold, and escape below: a change detail carries the key path the
|
|
60
|
+
# server names it by, and a name in brackets (`sources[hubspot].config`)
|
|
61
|
+
# is both valid rich markup, which would swallow the name, and one
|
|
62
|
+
# unbreakable word, which ellipsizing would cut at the wrong end.
|
|
63
|
+
table.add_column("Detail", overflow="fold")
|
|
59
64
|
for change in changes:
|
|
60
|
-
scope = change.get("entity") or change.get("event_flow") or ""
|
|
61
|
-
detail = change.get("detail", "")
|
|
62
|
-
table.add_row(
|
|
65
|
+
scope = escape(str(change.get("entity") or change.get("event_flow") or ""))
|
|
66
|
+
detail = escape(str(change.get("detail", "")))
|
|
67
|
+
table.add_row(
|
|
68
|
+
_mark(change.get("type", "unknown")),
|
|
69
|
+
f"[bold]{scope}[/bold] {detail}" if scope else detail,
|
|
70
|
+
)
|
|
63
71
|
console.print(table)
|
|
64
72
|
|
|
65
73
|
render_preflight(console, client.preflight(context.connector_id))
|
|
@@ -4,7 +4,12 @@ from pathlib import Path
|
|
|
4
4
|
import click
|
|
5
5
|
from rich.console import Console
|
|
6
6
|
|
|
7
|
-
from plugsync_cli.context import
|
|
7
|
+
from plugsync_cli.context import (
|
|
8
|
+
build_client,
|
|
9
|
+
describe_config,
|
|
10
|
+
fail,
|
|
11
|
+
warn_if_flow_order_is_not_preserved,
|
|
12
|
+
)
|
|
8
13
|
from plugsync_cli.serializer import write_config_directory, write_local_state
|
|
9
14
|
|
|
10
15
|
console = Console()
|
|
@@ -61,10 +66,5 @@ def pull(connector_name: str, revision: int | None, output: str | None):
|
|
|
61
66
|
|
|
62
67
|
warn_if_flow_order_is_not_preserved(config)
|
|
63
68
|
|
|
64
|
-
entities = len(config.get("entities") or [])
|
|
65
|
-
flows = len(config.get("flows") or [])
|
|
66
69
|
console.print(f"[green]Pulled {state['source']} to {output_dir}/[/green]")
|
|
67
|
-
console.print(
|
|
68
|
-
f" {entities} entit{'y' if entities == 1 else 'ies'}, "
|
|
69
|
-
f"{flows} flow{'' if flows == 1 else 's'}"
|
|
70
|
-
)
|
|
70
|
+
console.print(f" {describe_config(config)}")
|
|
@@ -9,7 +9,13 @@ config to production as a side effect of saving a file (#1241).
|
|
|
9
9
|
import click
|
|
10
10
|
from rich.console import Console
|
|
11
11
|
|
|
12
|
-
from plugsync_cli.context import
|
|
12
|
+
from plugsync_cli.context import (
|
|
13
|
+
build_client,
|
|
14
|
+
describe_config,
|
|
15
|
+
fail,
|
|
16
|
+
load_context,
|
|
17
|
+
write_draft_from_directory,
|
|
18
|
+
)
|
|
13
19
|
from plugsync_cli.findings import render_findings, render_preflight
|
|
14
20
|
|
|
15
21
|
console = Console()
|
|
@@ -87,12 +93,9 @@ def push(
|
|
|
87
93
|
console.print(f"Pushing [bold]{context.directory.name}[/bold] to the draft...")
|
|
88
94
|
draft = write_draft_from_directory(client, context, force=force)
|
|
89
95
|
|
|
90
|
-
config = draft["config"]
|
|
91
|
-
entities = len(config.get("entities") or [])
|
|
92
|
-
flows = len(config.get("flows") or [])
|
|
93
96
|
console.print(
|
|
94
97
|
f"[green]Draft updated (draft v{draft['draft_version']}): "
|
|
95
|
-
f"{
|
|
98
|
+
f"{describe_config(draft['config'])}[/green]"
|
|
96
99
|
)
|
|
97
100
|
|
|
98
101
|
if not do_publish:
|
|
@@ -134,7 +137,37 @@ def _publish(client, connector_id: str, message: str | None, republish: bool = F
|
|
|
134
137
|
)
|
|
135
138
|
return
|
|
136
139
|
console.print(f"[green]Published v{result.get('version')}[/green]")
|
|
140
|
+
_reveal_created_secrets(result.get("created_sources") or [])
|
|
137
141
|
warnings = result.get("warnings") or []
|
|
138
142
|
if warnings:
|
|
139
143
|
console.print("\nPublished with warnings:")
|
|
140
144
|
render_findings(console, warnings)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _reveal_created_secrets(created_sources: list[dict]) -> None:
|
|
148
|
+
"""Print the plaintext secrets of the webhook sources this publish created.
|
|
149
|
+
|
|
150
|
+
A `webhook` source declared in the config is created by the publish itself
|
|
151
|
+
(ADR-0026), and the publish response is the ONLY place its secret ever
|
|
152
|
+
appears: nothing reads it back afterwards. Printing it here is what keeps
|
|
153
|
+
`plugsync push --publish` a complete way to stand a webhook source up;
|
|
154
|
+
without it the only way to a usable secret is to rotate the one that was
|
|
155
|
+
just generated. Sources with no secret to generate (every non-webhook
|
|
156
|
+
type) are skipped rather than printed as a bare name.
|
|
157
|
+
"""
|
|
158
|
+
revealed = [s for s in created_sources if s.get("secret")]
|
|
159
|
+
if not revealed:
|
|
160
|
+
return
|
|
161
|
+
plural = "s" if len(revealed) > 1 else ""
|
|
162
|
+
console.print(
|
|
163
|
+
f"\n[bold]Webhook secret{plural} for the source{plural} this publish "
|
|
164
|
+
f"created - shown only once:[/bold]"
|
|
165
|
+
)
|
|
166
|
+
for source in revealed:
|
|
167
|
+
console.print(f" {source['name']}: [bold]{source['secret']}[/bold]")
|
|
168
|
+
console.print(
|
|
169
|
+
"[yellow]Copy each value now and configure it in the external system "
|
|
170
|
+
"that signs that source's deliveries. A secret is never shown again: "
|
|
171
|
+
"if one is lost, rotate the secret of that source from the dashboard "
|
|
172
|
+
"to issue a new one.[/yellow]"
|
|
173
|
+
)
|
|
@@ -9,17 +9,20 @@ from dataclasses import dataclass
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import NoReturn
|
|
11
11
|
|
|
12
|
+
import httpx
|
|
12
13
|
from rich.console import Console
|
|
13
14
|
|
|
14
|
-
from plugsync_cli.client import DraftConflict, PlugSyncClient
|
|
15
|
+
from plugsync_cli.client import DraftConflict, PlugSyncClient, describe_http_error
|
|
15
16
|
from plugsync_cli.serializer import (
|
|
16
17
|
CONFIG_FILE_NAME,
|
|
17
18
|
ConfigDirectoryError,
|
|
18
19
|
Normalization,
|
|
19
20
|
canonical_config,
|
|
20
21
|
flows_sharing_a_trigger,
|
|
22
|
+
locate_in_directory,
|
|
21
23
|
read_config_directory,
|
|
22
24
|
read_local_state,
|
|
25
|
+
undeclared_collection_message,
|
|
23
26
|
write_config_directory,
|
|
24
27
|
write_local_state,
|
|
25
28
|
)
|
|
@@ -128,10 +131,11 @@ def write_draft_from_directory(
|
|
|
128
131
|
cosmetic: it is what `validate` and `preview` warn about, and a warning that
|
|
129
132
|
fires every time stops being read exactly when it matters.
|
|
130
133
|
"""
|
|
134
|
+
config = context.config()
|
|
131
135
|
expected_version = None if force else _expected_draft_version(client, context)
|
|
132
136
|
try:
|
|
133
137
|
draft = client.put_draft(
|
|
134
|
-
context.connector_id,
|
|
138
|
+
context.connector_id, config, expected_version=expected_version
|
|
135
139
|
)
|
|
136
140
|
except DraftConflict as conflict:
|
|
137
141
|
fail(
|
|
@@ -139,6 +143,8 @@ def write_draft_from_directory(
|
|
|
139
143
|
"it. Re-run 'plugsync pull' and reapply your changes, or "
|
|
140
144
|
"'plugsync push --force' to overwrite theirs."
|
|
141
145
|
)
|
|
146
|
+
except httpx.HTTPStatusError as exc:
|
|
147
|
+
fail(_rejection_pointing_at_the_files(exc, context))
|
|
142
148
|
|
|
143
149
|
warn_if_flow_order_is_not_preserved(draft["config"])
|
|
144
150
|
write_config_directory(context.directory, draft["config"])
|
|
@@ -155,6 +161,69 @@ def write_draft_from_directory(
|
|
|
155
161
|
return draft
|
|
156
162
|
|
|
157
163
|
|
|
164
|
+
# The API's error code for a draft write that omits `sources` on a connector
|
|
165
|
+
# that declares them. `read_config_directory` refuses such a directory before
|
|
166
|
+
# any request, so reaching it means the check was bypassed or the API moved.
|
|
167
|
+
SOURCES_KEY_REMOVAL_ERROR = "sources_key_removal_not_supported"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _api_error_code(exc: httpx.HTTPStatusError) -> str | None:
|
|
171
|
+
"""The machine-readable `detail.error` of a structured API refusal."""
|
|
172
|
+
try:
|
|
173
|
+
detail = exc.response.json().get("detail")
|
|
174
|
+
except (ValueError, AttributeError):
|
|
175
|
+
return None
|
|
176
|
+
return detail.get("error") if isinstance(detail, dict) else None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _rejection_pointing_at_the_files(
|
|
180
|
+
exc: httpx.HTTPStatusError, context: ConnectorContext
|
|
181
|
+
) -> str:
|
|
182
|
+
"""The API's refusal, with every config path it names resolved to a file.
|
|
183
|
+
|
|
184
|
+
A rejected draft is described the way the config document reads
|
|
185
|
+
(`sources[hubspot].config.triggers[0].pattern`), which is not how the
|
|
186
|
+
operator is looking at it: they have a directory of files open. Naming the
|
|
187
|
+
file that holds the key, alongside the backend's own explanation, is what
|
|
188
|
+
makes the failure actionable without decoding the path by hand.
|
|
189
|
+
|
|
190
|
+
A refusal to drop `sources` is about the directory as a whole, not a key in
|
|
191
|
+
it, so it gets the CLI's own explanation instead of the server's JSON.
|
|
192
|
+
"""
|
|
193
|
+
if _api_error_code(exc) == SOURCES_KEY_REMOVAL_ERROR:
|
|
194
|
+
return undeclared_collection_message(context.directory, "sources")
|
|
195
|
+
message = describe_http_error(exc)
|
|
196
|
+
located = locate_in_directory(message)
|
|
197
|
+
if not located:
|
|
198
|
+
return message
|
|
199
|
+
lines = "\n".join(
|
|
200
|
+
f" {file_name}: {key}" if key else f" {file_name}" for file_name, key in located
|
|
201
|
+
)
|
|
202
|
+
return f"{message}\n\nIn your files:\n{lines}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def describe_config(config: dict) -> str:
|
|
206
|
+
"""What a config holds, counted, for the line a pull or a push ends on.
|
|
207
|
+
|
|
208
|
+
`sources` is reported only when the config declares the key. Only a
|
|
209
|
+
version published before the sources were part of the config lacks it, and
|
|
210
|
+
that is not the same as having none: printing "0 sources" would say
|
|
211
|
+
something the version never said.
|
|
212
|
+
"""
|
|
213
|
+
counted = [
|
|
214
|
+
_counted(config.get("entities"), "entity", "entities"),
|
|
215
|
+
_counted(config.get("flows"), "flow", "flows"),
|
|
216
|
+
]
|
|
217
|
+
if config.get("sources") is not None:
|
|
218
|
+
counted.append(_counted(config["sources"], "source", "sources"))
|
|
219
|
+
return ", ".join(counted)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _counted(items, singular: str, plural: str) -> str:
|
|
223
|
+
count = len(items or [])
|
|
224
|
+
return f"{count} {singular if count == 1 else plural}"
|
|
225
|
+
|
|
226
|
+
|
|
158
227
|
def _expected_draft_version(client: PlugSyncClient, context: ConnectorContext) -> int:
|
|
159
228
|
"""The draft version a write guards itself with.
|
|
160
229
|
|
|
@@ -8,6 +8,7 @@ severity therefore gets a glyph, a colour AND the literal word -- the word being
|
|
|
8
8
|
the part that still reads correctly in a CI log with colour stripped.
|
|
9
9
|
"""
|
|
10
10
|
from rich.console import Console
|
|
11
|
+
from rich.markup import escape
|
|
11
12
|
|
|
12
13
|
# severity -> (glyph, rich colour). Ordered worst-first: that is the order
|
|
13
14
|
# findings are printed in, so the thing that blocks you is not below the noise.
|
|
@@ -34,13 +35,21 @@ def _summary(counts: dict[str, int]) -> str:
|
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
def _render_one(console: Console, finding: dict) -> None:
|
|
38
|
+
"""One finding, with everything the backend wrote printed verbatim.
|
|
39
|
+
|
|
40
|
+
`escape` on every backend-authored string: a finding names the key it is
|
|
41
|
+
about the way the config document does, and a name in brackets --
|
|
42
|
+
`sources[hubspot].config.triggers[0].pattern` -- is valid rich markup. Rich
|
|
43
|
+
read `[hubspot]` as a style tag and swallowed it, so the one word saying
|
|
44
|
+
WHICH source was wrong disappeared from the screen.
|
|
45
|
+
"""
|
|
37
46
|
severity = finding.get("severity", "info")
|
|
38
47
|
# A severity this CLI does not know still gets printed, under its own name.
|
|
39
48
|
glyph, colour = SEVERITY_STYLES.get(severity, ("?", "magenta"))
|
|
40
|
-
code = finding.get("code", "unknown")
|
|
49
|
+
code = escape(str(finding.get("code", "unknown")))
|
|
50
|
+
message = escape(str(finding.get("message", "")))
|
|
41
51
|
console.print(
|
|
42
|
-
f" [{colour}]{glyph} {severity:<7}[/{colour}] "
|
|
43
|
-
f"[bold]{code}[/bold]: {finding.get('message', '')}"
|
|
52
|
+
f" [{colour}]{glyph} {severity:<7}[/{colour}] [bold]{code}[/bold]: {message}"
|
|
44
53
|
)
|
|
45
54
|
scope = finding.get("entity") or finding.get("event_flow")
|
|
46
55
|
for label, value in (
|
|
@@ -49,7 +58,7 @@ def _render_one(console: Console, finding: dict) -> None:
|
|
|
49
58
|
("fix", finding.get("suggestion")),
|
|
50
59
|
):
|
|
51
60
|
if value:
|
|
52
|
-
console.print(f" [dim]{label}:[/dim] {value}")
|
|
61
|
+
console.print(f" [dim]{label}:[/dim] {escape(str(value))}")
|
|
53
62
|
|
|
54
63
|
|
|
55
64
|
def _severity_rank(severity: str) -> int:
|