plugsync-cli 0.4.0__tar.gz → 0.6.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.4.0 → plugsync_cli-0.6.0}/PKG-INFO +3 -1
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/README.md +2 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/__init__.py +1 -1
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/client.py +25 -0
- plugsync_cli-0.6.0/plugsync_cli/commands/connector.py +253 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/diff.py +49 -2
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/preview.py +12 -4
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/pull.py +7 -7
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/push.py +8 -5
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/context.py +87 -5
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/findings.py +13 -4
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/main.py +2 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/serializer.py +182 -31
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/PKG-INFO +3 -1
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/SOURCES.txt +2 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/pyproject.toml +1 -1
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_api_contract.py +4 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_commands.py +333 -0
- plugsync_cli-0.6.0/tests/test_connector_commands.py +285 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_e2e_draft_api.py +91 -0
- plugsync_cli-0.6.0/tests/test_serializer.py +445 -0
- plugsync_cli-0.4.0/tests/test_serializer.py +0 -239
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/bundler.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/__init__.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/auth.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/log.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/plugin.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/rollback.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/validate.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/compat.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/config.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/schema_defaults.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/requires.txt +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/top_level.txt +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/setup.cfg +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_auth_commands.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_client.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_compat.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_config.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_hand_edited_yaml.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_plugin_commands.py +0 -0
- {plugsync_cli-0.4.0 → plugsync_cli-0.6.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.6.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
|
|
@@ -75,6 +75,8 @@ override the config file, handy for CI.
|
|
|
75
75
|
| `plugsync preview <name>` | Preview a publish's effects and its preflight findings |
|
|
76
76
|
| `plugsync log <name>` | Show a connector's published version history |
|
|
77
77
|
| `plugsync rollback <name>` | Restore a published version into the draft |
|
|
78
|
+
| `plugsync connector breakers <name>` | List the connector's flows whose circuit breaker is open |
|
|
79
|
+
| `plugsync connector flow retry <name> <flow>` | Retry one blocked flow now, instead of waiting for its automatic attempt |
|
|
78
80
|
| `plugsync plugin init/push/list/status/logs/metrics/invoke/promote/publish/rollback` | Author and manage plugins (Enterprise tier) |
|
|
79
81
|
|
|
80
82
|
Run `plugsync --help` or `plugsync <command> --help` for the full option list.
|
|
@@ -44,6 +44,8 @@ override the config file, handy for CI.
|
|
|
44
44
|
| `plugsync preview <name>` | Preview a publish's effects and its preflight findings |
|
|
45
45
|
| `plugsync log <name>` | Show a connector's published version history |
|
|
46
46
|
| `plugsync rollback <name>` | Restore a published version into the draft |
|
|
47
|
+
| `plugsync connector breakers <name>` | List the connector's flows whose circuit breaker is open |
|
|
48
|
+
| `plugsync connector flow retry <name> <flow>` | Retry one blocked flow now, instead of waiting for its automatic attempt |
|
|
47
49
|
| `plugsync plugin init/push/list/status/logs/metrics/invoke/promote/publish/rollback` | Author and manage plugins (Enterprise tier) |
|
|
48
50
|
|
|
49
51
|
Run `plugsync --help` or `plugsync <command> --help` for the full option list.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""PluSync CLI — git-like connector management."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.6.0"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""HTTP client wrapper for PluSync REST API."""
|
|
2
2
|
import json
|
|
3
|
+
from urllib.parse import quote
|
|
3
4
|
|
|
4
5
|
import httpx
|
|
5
6
|
|
|
@@ -120,6 +121,30 @@ class PlugSyncClient:
|
|
|
120
121
|
r.raise_for_status()
|
|
121
122
|
return r.json()
|
|
122
123
|
|
|
124
|
+
def retry_flow(self, connector_id: str, flow_name: str) -> dict:
|
|
125
|
+
"""Retry one flow whose circuit breaker is open, returning the
|
|
126
|
+
refreshed connector.
|
|
127
|
+
|
|
128
|
+
The API moves the breaker to `half_open`, which makes the next message
|
|
129
|
+
through that flow the recovery probe: it is not a close, and a failed
|
|
130
|
+
probe re-opens the breaker with a longer cooldown. Idempotent when the
|
|
131
|
+
breaker is already half-open; 404 when the flow has no open breaker at
|
|
132
|
+
all, which the command translates into "nothing to retry".
|
|
133
|
+
|
|
134
|
+
The flow name is a config value, not a URL-safe token, so it is quoted
|
|
135
|
+
with no safe characters: a flow called `orders/eu` has to reach the API
|
|
136
|
+
as one path segment rather than two.
|
|
137
|
+
"""
|
|
138
|
+
with self._client() as c:
|
|
139
|
+
r = c.post(
|
|
140
|
+
self._url(
|
|
141
|
+
f"/connectors/{connector_id}/flows/"
|
|
142
|
+
f"{quote(flow_name, safe='')}/retry"
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
r.raise_for_status()
|
|
146
|
+
return r.json()
|
|
147
|
+
|
|
123
148
|
def get_draft(self, connector_id: str) -> dict:
|
|
124
149
|
"""The connector's editable config (backend DraftResponse).
|
|
125
150
|
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""plugsync connector -- read a connector's flow circuit breakers, retry one.
|
|
2
|
+
|
|
3
|
+
Self-service only, with the same org-scoped auth as every other command: the
|
|
4
|
+
owner of a connector lists the flows whose circuit breaker is open and retries
|
|
5
|
+
one of them, from a script or from CI, without opening the dashboard. The
|
|
6
|
+
platform-side force-close is a different operation with a different
|
|
7
|
+
authentication contract (it refuses an org API key) and deliberately has no
|
|
8
|
+
command here.
|
|
9
|
+
"""
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
import httpx
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from plugsync_cli.client import PlugSyncClient
|
|
19
|
+
from plugsync_cli.context import build_client, fail
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
_CONNECTOR_NOT_FOUND = (
|
|
24
|
+
"Connector '{label}' not found, or it does not belong to this organization."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _looks_like_a_connector_id(reference: str) -> bool:
|
|
29
|
+
try:
|
|
30
|
+
uuid.UUID(reference)
|
|
31
|
+
except ValueError:
|
|
32
|
+
return False
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_connector(client: PlugSyncClient, reference: str) -> tuple[str, str]:
|
|
37
|
+
"""A connector reference (name or id) as `(connector_id, label)`.
|
|
38
|
+
|
|
39
|
+
A UUID is taken as the id and used as-is: resolving it through the
|
|
40
|
+
connector listing would cost a round trip to learn a name the endpoints
|
|
41
|
+
already return in their own response.
|
|
42
|
+
"""
|
|
43
|
+
if _looks_like_a_connector_id(reference):
|
|
44
|
+
return reference, reference
|
|
45
|
+
connector = client.find_connector(reference)
|
|
46
|
+
if not connector:
|
|
47
|
+
fail(f"Connector '{reference}' not found")
|
|
48
|
+
return connector["id"], connector.get("name") or reference
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _detail(exc: httpx.HTTPStatusError) -> str:
|
|
52
|
+
"""The API's `detail` string, or "" when the body carries none."""
|
|
53
|
+
try:
|
|
54
|
+
payload = exc.response.json()
|
|
55
|
+
except ValueError:
|
|
56
|
+
return ""
|
|
57
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
58
|
+
return detail if isinstance(detail, str) else ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _open_breakers(connector: dict) -> list[dict]:
|
|
62
|
+
"""The connector's non-closed flow breakers, one shape for two payloads.
|
|
63
|
+
|
|
64
|
+
`health.flows` is the richer form and the one to read. An API that predates
|
|
65
|
+
it answers only the legacy list, which carries the same flows under other
|
|
66
|
+
key names: normalizing it here keeps such a backend from reading as a
|
|
67
|
+
connector with nothing blocked.
|
|
68
|
+
"""
|
|
69
|
+
flows = (connector.get("health") or {}).get("flows")
|
|
70
|
+
if flows:
|
|
71
|
+
return flows
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
"name": flow.get("flow_name"),
|
|
75
|
+
"state": flow.get("state"),
|
|
76
|
+
"detail": flow.get("reason"),
|
|
77
|
+
"opened_at": flow.get("tripped_at"),
|
|
78
|
+
}
|
|
79
|
+
for flow in connector.get("tripped_flows") or []
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _breaker_for(connector: dict, flow_name: str) -> dict | None:
|
|
84
|
+
for breaker in _open_breakers(connector):
|
|
85
|
+
if breaker.get("name") == flow_name:
|
|
86
|
+
return breaker
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _reason(breaker: dict) -> str:
|
|
91
|
+
"""The human-readable diagnostic, falling back to the machine code."""
|
|
92
|
+
return str(breaker.get("detail") or breaker.get("reason") or "")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _relative(value, *, now: datetime | None = None) -> str:
|
|
96
|
+
"""An ISO timestamp as a short relative time, past or future.
|
|
97
|
+
|
|
98
|
+
Both directions matter in one table: a breaker opened in the past, and a
|
|
99
|
+
cooldown that expires in the future. An unparseable value is printed as it
|
|
100
|
+
arrived rather than dropped.
|
|
101
|
+
"""
|
|
102
|
+
if not value:
|
|
103
|
+
return ""
|
|
104
|
+
try:
|
|
105
|
+
moment = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
106
|
+
except ValueError:
|
|
107
|
+
return str(value)
|
|
108
|
+
if moment.tzinfo is None:
|
|
109
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
110
|
+
|
|
111
|
+
delta = (moment - (now or datetime.now(timezone.utc))).total_seconds()
|
|
112
|
+
seconds = int(abs(delta))
|
|
113
|
+
if seconds < 60:
|
|
114
|
+
return "now"
|
|
115
|
+
if seconds < 3600:
|
|
116
|
+
magnitude = f"{seconds // 60}m"
|
|
117
|
+
elif seconds < 86400:
|
|
118
|
+
magnitude = f"{seconds // 3600}h"
|
|
119
|
+
else:
|
|
120
|
+
magnitude = f"{seconds // 86400}d"
|
|
121
|
+
return f"in {magnitude}" if delta > 0 else f"{magnitude} ago"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@click.group()
|
|
125
|
+
def connector():
|
|
126
|
+
"""Inspect a connector's operational state."""
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@connector.group()
|
|
131
|
+
def flow():
|
|
132
|
+
"""Act on one event flow of a connector."""
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@flow.command("retry")
|
|
137
|
+
@click.argument("connector_reference", metavar="CONNECTOR")
|
|
138
|
+
@click.argument("flow_name", metavar="FLOW")
|
|
139
|
+
def flow_retry(connector_reference: str, flow_name: str):
|
|
140
|
+
"""Retry a flow whose circuit breaker is open.
|
|
141
|
+
|
|
142
|
+
CONNECTOR is a connector name or id, FLOW the event flow name. The breaker
|
|
143
|
+
moves to half-open: the next message through the flow is the recovery
|
|
144
|
+
probe. It is not a force-close, and a failed probe re-opens the breaker
|
|
145
|
+
with a longer cooldown.
|
|
146
|
+
|
|
147
|
+
Examples:
|
|
148
|
+
plugsync connector flow retry my-connector shop-orders
|
|
149
|
+
"""
|
|
150
|
+
client = build_client()
|
|
151
|
+
connector_id, label = _resolve_connector(client, connector_reference)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
updated = client.retry_flow(connector_id, flow_name)
|
|
155
|
+
except httpx.HTTPStatusError as exc:
|
|
156
|
+
if exc.response.status_code != 404:
|
|
157
|
+
raise
|
|
158
|
+
if "connector not found" in _detail(exc).lower():
|
|
159
|
+
fail(_CONNECTOR_NOT_FOUND.format(label=label))
|
|
160
|
+
fail(
|
|
161
|
+
f"Flow '{flow_name}' has no open circuit breaker on '{label}': "
|
|
162
|
+
f"nothing to retry. Run 'plugsync connector breakers {label}' to "
|
|
163
|
+
"list the flows that are blocked."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
name = updated.get("name") or label
|
|
167
|
+
breaker = _breaker_for(updated, flow_name)
|
|
168
|
+
if breaker is None:
|
|
169
|
+
console.print(
|
|
170
|
+
f"Retry accepted for flow '{flow_name}' on connector '{name}', but "
|
|
171
|
+
"the flow no longer appears among its open circuit breakers: there "
|
|
172
|
+
"is no breaker state left to report.",
|
|
173
|
+
soft_wrap=True,
|
|
174
|
+
)
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
state = breaker.get("state") or "unknown"
|
|
178
|
+
console.print(
|
|
179
|
+
f"[green]Flow '{flow_name}' on connector '{name}': breaker is now "
|
|
180
|
+
f"{state}.[/green]",
|
|
181
|
+
soft_wrap=True,
|
|
182
|
+
)
|
|
183
|
+
if state == "half_open":
|
|
184
|
+
console.print(
|
|
185
|
+
"The next message through this flow is the recovery probe: if it "
|
|
186
|
+
"succeeds the breaker closes, if it fails the breaker re-opens "
|
|
187
|
+
"with a longer cooldown.",
|
|
188
|
+
soft_wrap=True,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@connector.command("breakers")
|
|
193
|
+
@click.argument("connector_reference", metavar="CONNECTOR")
|
|
194
|
+
def connector_breakers(connector_reference: str):
|
|
195
|
+
"""List the flows of a connector whose circuit breaker is open.
|
|
196
|
+
|
|
197
|
+
CONNECTOR is a connector name or id. Read-only, and always exits 0 when it
|
|
198
|
+
could read the connector: a blocked flow is not a command failure.
|
|
199
|
+
|
|
200
|
+
Examples:
|
|
201
|
+
plugsync connector breakers my-connector
|
|
202
|
+
"""
|
|
203
|
+
client = build_client()
|
|
204
|
+
connector_id, label = _resolve_connector(client, connector_reference)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
data = client.get_connector(connector_id)
|
|
208
|
+
except httpx.HTTPStatusError as exc:
|
|
209
|
+
if exc.response.status_code != 404:
|
|
210
|
+
raise
|
|
211
|
+
fail(_CONNECTOR_NOT_FOUND.format(label=label))
|
|
212
|
+
|
|
213
|
+
name = data.get("name") or label
|
|
214
|
+
breakers = _open_breakers(data)
|
|
215
|
+
if not breakers:
|
|
216
|
+
console.print(f"[green]No open circuit breaker on '{name}'.[/green]")
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
220
|
+
# `overflow="fold"` on the flow name: rich shrinks the widest column when
|
|
221
|
+
# the table outgrows the terminal, and the default is an ellipsis. The
|
|
222
|
+
# name is exactly what the operator has to retype into `flow retry`, so it
|
|
223
|
+
# wraps rather than being cut. `Reason` comes last for the same reason --
|
|
224
|
+
# it is the column the wrapping should land on.
|
|
225
|
+
table.add_column("Flow", overflow="fold")
|
|
226
|
+
table.add_column("State")
|
|
227
|
+
table.add_column("Opened")
|
|
228
|
+
table.add_column("Retry after")
|
|
229
|
+
table.add_column("Opens", justify="right")
|
|
230
|
+
table.add_column("Reason", overflow="fold")
|
|
231
|
+
|
|
232
|
+
for breaker in breakers:
|
|
233
|
+
opens = breaker.get("opens")
|
|
234
|
+
table.add_row(
|
|
235
|
+
str(breaker.get("name") or ""),
|
|
236
|
+
str(breaker.get("state") or ""),
|
|
237
|
+
_relative(breaker.get("opened_at")),
|
|
238
|
+
_relative(breaker.get("retry_after_at")),
|
|
239
|
+
"" if opens is None else str(opens),
|
|
240
|
+
_reason(breaker),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
console.print(f"\n[bold]Open circuit breakers: {name}[/bold]\n")
|
|
244
|
+
console.print(table)
|
|
245
|
+
console.print(
|
|
246
|
+
f"\nRetry one now with: plugsync connector flow retry {name} <flow>",
|
|
247
|
+
soft_wrap=True,
|
|
248
|
+
)
|
|
249
|
+
console.print(
|
|
250
|
+
"A flow whose retry window has already passed is probed automatically "
|
|
251
|
+
"by the next message it receives.\n",
|
|
252
|
+
soft_wrap=True,
|
|
253
|
+
)
|
|
@@ -51,15 +51,20 @@ 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.
|
|
67
|
+
COLLECTIONS = {"entities": "entity", "flows": "flow", "sources": "source"}
|
|
63
68
|
|
|
64
69
|
# Keys listed in one Detail cell before the rest is summarized. A row exists to
|
|
65
70
|
# tell an operator where to look, and forty paths in a table cell do not.
|
|
@@ -198,6 +203,7 @@ def _diff_configs(local: dict, remote: dict, root: SchemaNode) -> list[dict]:
|
|
|
198
203
|
|
|
199
204
|
for key, label in COLLECTIONS.items():
|
|
200
205
|
member = root.child(key).item()
|
|
206
|
+
changes.extend(_declaration_change(key, local, remote))
|
|
201
207
|
local_items, remote_items = _by_name(local.get(key)), _by_name(remote.get(key))
|
|
202
208
|
for name in sorted(set(local_items) | set(remote_items)):
|
|
203
209
|
if name not in remote_items:
|
|
@@ -228,6 +234,42 @@ def _diff_configs(local: dict, remote: dict, root: SchemaNode) -> list[dict]:
|
|
|
228
234
|
return changes
|
|
229
235
|
|
|
230
236
|
|
|
237
|
+
def _declaration_change(key: str, local: dict, remote: dict) -> list[dict]:
|
|
238
|
+
"""The row for a collection one side declares and the other does not.
|
|
239
|
+
|
|
240
|
+
Only an optional collection can be in that position, and for `sources` the
|
|
241
|
+
difference between "declared, and empty" and "not declared at all" is the
|
|
242
|
+
whole point of the key: the first puts the definitions under the
|
|
243
|
+
connector's config, the second leaves them to whatever manages them outside
|
|
244
|
+
it. Item by item the two sides look identical -- no sources either way --
|
|
245
|
+
so without this row the operator sees nothing at all, and the direction
|
|
246
|
+
that DROPS the key is the one that reads as "no changes" while changing
|
|
247
|
+
which document owns the definitions.
|
|
248
|
+
"""
|
|
249
|
+
if key not in OPTIONAL_COLLECTIONS:
|
|
250
|
+
return []
|
|
251
|
+
in_local, in_remote = local.get(key) is not None, remote.get(key) is not None
|
|
252
|
+
if in_local == in_remote:
|
|
253
|
+
return []
|
|
254
|
+
if in_local:
|
|
255
|
+
return [
|
|
256
|
+
_simple(
|
|
257
|
+
f"{key}_key_added",
|
|
258
|
+
key,
|
|
259
|
+
f"declared in your files, absent from the published version: "
|
|
260
|
+
f"publishing them puts the {key} under the connector's config",
|
|
261
|
+
)
|
|
262
|
+
]
|
|
263
|
+
return [
|
|
264
|
+
_simple(
|
|
265
|
+
f"{key}_key_removed",
|
|
266
|
+
key,
|
|
267
|
+
f"declared in the published version, absent from your files: "
|
|
268
|
+
f"pushing this directory takes the {key} out of the config",
|
|
269
|
+
)
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
|
|
231
273
|
def _simple(change_type: str, scope: str, detail: str) -> dict:
|
|
232
274
|
return {
|
|
233
275
|
"type": change_type,
|
|
@@ -264,7 +306,12 @@ def _table(changes: list[dict]) -> Table:
|
|
|
264
306
|
# name -- which is the part this column exists to show.
|
|
265
307
|
table.add_column("Detail", overflow="fold")
|
|
266
308
|
for change in changes:
|
|
267
|
-
|
|
309
|
+
# escape: item names and key paths come from the config, and one in
|
|
310
|
+
# brackets is valid rich markup that would be read as a style tag and
|
|
311
|
+
# swallowed, taking the name this column exists to show with it.
|
|
312
|
+
table.add_row(
|
|
313
|
+
_mark(change["type"]), escape(change["scope"]), escape(change["detail"])
|
|
314
|
+
)
|
|
268
315
|
return table
|
|
269
316
|
|
|
270
317
|
|
|
@@ -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:
|
|
@@ -9,15 +9,17 @@ 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,
|
|
23
25
|
write_config_directory,
|
|
@@ -128,10 +130,21 @@ def write_draft_from_directory(
|
|
|
128
130
|
cosmetic: it is what `validate` and `preview` warn about, and a warning that
|
|
129
131
|
fires every time stops being read exactly when it matters.
|
|
130
132
|
"""
|
|
131
|
-
|
|
133
|
+
config = context.config()
|
|
134
|
+
# Read once, shared: the warning below and the concurrency guard both want
|
|
135
|
+
# the remote draft, and neither is worth a round trip of its own.
|
|
136
|
+
remote_draft = (
|
|
137
|
+
client.get_draft(context.connector_id) if config.get("sources") is None else None
|
|
138
|
+
)
|
|
139
|
+
if remote_draft is not None:
|
|
140
|
+
warn_if_the_push_takes_the_sources_out_of_the_config(remote_draft["config"])
|
|
141
|
+
|
|
142
|
+
expected_version = (
|
|
143
|
+
None if force else _expected_draft_version(client, context, remote_draft)
|
|
144
|
+
)
|
|
132
145
|
try:
|
|
133
146
|
draft = client.put_draft(
|
|
134
|
-
context.connector_id,
|
|
147
|
+
context.connector_id, config, expected_version=expected_version
|
|
135
148
|
)
|
|
136
149
|
except DraftConflict as conflict:
|
|
137
150
|
fail(
|
|
@@ -139,6 +152,8 @@ def write_draft_from_directory(
|
|
|
139
152
|
"it. Re-run 'plugsync pull' and reapply your changes, or "
|
|
140
153
|
"'plugsync push --force' to overwrite theirs."
|
|
141
154
|
)
|
|
155
|
+
except httpx.HTTPStatusError as exc:
|
|
156
|
+
fail(_rejection_pointing_at_the_files(exc))
|
|
142
157
|
|
|
143
158
|
warn_if_flow_order_is_not_preserved(draft["config"])
|
|
144
159
|
write_config_directory(context.directory, draft["config"])
|
|
@@ -155,17 +170,84 @@ def write_draft_from_directory(
|
|
|
155
170
|
return draft
|
|
156
171
|
|
|
157
172
|
|
|
158
|
-
def
|
|
173
|
+
def _rejection_pointing_at_the_files(exc: httpx.HTTPStatusError) -> str:
|
|
174
|
+
"""The API's refusal, with every config path it names resolved to a file.
|
|
175
|
+
|
|
176
|
+
A rejected draft is described the way the config document reads
|
|
177
|
+
(`sources[hubspot].config.triggers[0].pattern`), which is not how the
|
|
178
|
+
operator is looking at it: they have a directory of files open. Naming the
|
|
179
|
+
file that holds the key, alongside the backend's own explanation, is what
|
|
180
|
+
makes the failure actionable without decoding the path by hand.
|
|
181
|
+
"""
|
|
182
|
+
message = describe_http_error(exc)
|
|
183
|
+
located = locate_in_directory(message)
|
|
184
|
+
if not located:
|
|
185
|
+
return message
|
|
186
|
+
lines = "\n".join(
|
|
187
|
+
f" {file_name}: {key}" if key else f" {file_name}" for file_name, key in located
|
|
188
|
+
)
|
|
189
|
+
return f"{message}\n\nIn your files:\n{lines}"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def describe_config(config: dict) -> str:
|
|
193
|
+
"""What a config holds, counted, for the line a pull or a push ends on.
|
|
194
|
+
|
|
195
|
+
`sources` is reported only when the config declares the key. Its absence
|
|
196
|
+
means the connector's sources are not versioned in the config at all, which
|
|
197
|
+
is not the same as having none: printing "0 sources" would say the opposite
|
|
198
|
+
of what an absent key means.
|
|
199
|
+
"""
|
|
200
|
+
counted = [
|
|
201
|
+
_counted(config.get("entities"), "entity", "entities"),
|
|
202
|
+
_counted(config.get("flows"), "flow", "flows"),
|
|
203
|
+
]
|
|
204
|
+
if config.get("sources") is not None:
|
|
205
|
+
counted.append(_counted(config["sources"], "source", "sources"))
|
|
206
|
+
return ", ".join(counted)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _counted(items, singular: str, plural: str) -> str:
|
|
210
|
+
count = len(items or [])
|
|
211
|
+
return f"{count} {singular if count == 1 else plural}"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def warn_if_the_push_takes_the_sources_out_of_the_config(remote_config: dict) -> None:
|
|
215
|
+
"""Say it when this push drops a `sources` key the connector has.
|
|
216
|
+
|
|
217
|
+
A directory with no `sources/` sends a config with no `sources` key, and
|
|
218
|
+
that is not "this connector has no sources": it is the switch that hands
|
|
219
|
+
the source definitions back to being managed outside the config. A
|
|
220
|
+
directory pulled before the sources were part of it flips that switch on
|
|
221
|
+
the next push, silently and with a zero exit code, which is why the one
|
|
222
|
+
push that does it says so.
|
|
223
|
+
"""
|
|
224
|
+
if remote_config.get("sources") is None:
|
|
225
|
+
return
|
|
226
|
+
console.print(
|
|
227
|
+
"[yellow]The connector's draft declares 'sources' and this directory "
|
|
228
|
+
"does not, so this push takes the source definitions out of the "
|
|
229
|
+
"config: they go back to being managed outside it, and a later publish "
|
|
230
|
+
"leaves them alone. Run 'plugsync pull' first if you meant to keep them "
|
|
231
|
+
"in the directory.[/yellow]"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _expected_draft_version(
|
|
236
|
+
client: PlugSyncClient, context: ConnectorContext, draft: dict | None = None
|
|
237
|
+
) -> int:
|
|
159
238
|
"""The draft version a write guards itself with.
|
|
160
239
|
|
|
161
240
|
Normally the one recorded at pull time, which is what makes a concurrent
|
|
162
241
|
edit detectable. A directory pulled with `--revision` has none: there the
|
|
163
242
|
current draft version is read now, which still rules out a write racing this
|
|
164
243
|
command, and the weaker guarantee is stated out loud rather than assumed.
|
|
244
|
+
|
|
245
|
+
`draft` is the remote draft when the caller has already read it, so the two
|
|
246
|
+
reasons this function's caller has to fetch it cost one request, not two.
|
|
165
247
|
"""
|
|
166
248
|
if context.draft_version is not None:
|
|
167
249
|
return context.draft_version
|
|
168
|
-
current = client.get_draft(context.connector_id)["draft_version"]
|
|
250
|
+
current = (draft or client.get_draft(context.connector_id))["draft_version"]
|
|
169
251
|
console.print(
|
|
170
252
|
f"[yellow]This directory was pulled from a published version, not the "
|
|
171
253
|
f"draft: guarding against edits after draft v{current} only.[/yellow]"
|
|
@@ -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:
|