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.
Files changed (44) hide show
  1. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/PKG-INFO +3 -1
  2. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/README.md +2 -0
  3. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/__init__.py +1 -1
  4. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/client.py +25 -0
  5. plugsync_cli-0.6.0/plugsync_cli/commands/connector.py +253 -0
  6. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/diff.py +49 -2
  7. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/preview.py +12 -4
  8. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/pull.py +7 -7
  9. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/push.py +8 -5
  10. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/context.py +87 -5
  11. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/findings.py +13 -4
  12. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/main.py +2 -0
  13. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/serializer.py +182 -31
  14. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/PKG-INFO +3 -1
  15. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/SOURCES.txt +2 -0
  16. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/pyproject.toml +1 -1
  17. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_api_contract.py +4 -0
  18. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_commands.py +333 -0
  19. plugsync_cli-0.6.0/tests/test_connector_commands.py +285 -0
  20. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_e2e_draft_api.py +91 -0
  21. plugsync_cli-0.6.0/tests/test_serializer.py +445 -0
  22. plugsync_cli-0.4.0/tests/test_serializer.py +0 -239
  23. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/bundler.py +0 -0
  24. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/__init__.py +0 -0
  25. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/auth.py +0 -0
  26. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/log.py +0 -0
  27. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/plugin.py +0 -0
  28. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/rollback.py +0 -0
  29. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/validate.py +0 -0
  30. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/compat.py +0 -0
  31. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/config.py +0 -0
  32. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli/schema_defaults.py +0 -0
  33. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
  34. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
  35. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/requires.txt +0 -0
  36. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/top_level.txt +0 -0
  37. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/setup.cfg +0 -0
  38. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_auth_commands.py +0 -0
  39. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_client.py +0 -0
  40. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_compat.py +0 -0
  41. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_config.py +0 -0
  42. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_hand_edited_yaml.py +0 -0
  43. {plugsync_cli-0.4.0 → plugsync_cli-0.6.0}/tests/test_plugin_commands.py +0 -0
  44. {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.4.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.4.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
- COLLECTIONS = {"entities": "entity", "flows": "flow"}
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
- table.add_row(_mark(change["type"]), change["scope"], change["detail"])
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
- table.add_column("Detail")
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(_mark(change.get("type", "unknown")), f"[bold]{scope}[/bold] {detail}" if scope else detail)
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 build_client, fail, warn_if_flow_order_is_not_preserved
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 build_client, fail, load_context, write_draft_from_directory
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"{entities} entities, {flows} flows[/green]"
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
- expected_version = None if force else _expected_draft_version(client, context)
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, context.config(), expected_version=expected_version
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 _expected_draft_version(client: PlugSyncClient, context: ConnectorContext) -> int:
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: