plugsync-cli 0.3.0__tar.gz → 0.5.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.3.0 → plugsync_cli-0.5.0}/PKG-INFO +3 -1
  2. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/README.md +2 -0
  3. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/__init__.py +1 -1
  4. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/client.py +76 -4
  5. plugsync_cli-0.5.0/plugsync_cli/commands/connector.py +253 -0
  6. plugsync_cli-0.5.0/plugsync_cli/commands/diff.py +439 -0
  7. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/plugin.py +94 -7
  8. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/push.py +33 -4
  9. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/context.py +10 -1
  10. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/main.py +2 -0
  11. plugsync_cli-0.5.0/plugsync_cli/schema_defaults.py +183 -0
  12. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/serializer.py +202 -7
  13. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/PKG-INFO +3 -1
  14. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/SOURCES.txt +5 -0
  15. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/pyproject.toml +1 -1
  16. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_api_contract.py +5 -0
  17. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_commands.py +634 -1
  18. plugsync_cli-0.5.0/tests/test_connector_commands.py +285 -0
  19. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_e2e_draft_api.py +153 -2
  20. plugsync_cli-0.5.0/tests/test_hand_edited_yaml.py +341 -0
  21. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_plugin_commands.py +328 -0
  22. plugsync_cli-0.5.0/tests/test_schema_defaults.py +179 -0
  23. plugsync_cli-0.3.0/plugsync_cli/commands/diff.py +0 -122
  24. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/bundler.py +0 -0
  25. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/__init__.py +0 -0
  26. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/auth.py +0 -0
  27. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/log.py +0 -0
  28. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/preview.py +0 -0
  29. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/pull.py +0 -0
  30. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/rollback.py +0 -0
  31. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/validate.py +0 -0
  32. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/compat.py +0 -0
  33. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/config.py +0 -0
  34. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli/findings.py +0 -0
  35. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
  36. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
  37. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/requires.txt +0 -0
  38. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/top_level.txt +0 -0
  39. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/setup.cfg +0 -0
  40. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_auth_commands.py +0 -0
  41. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_client.py +0 -0
  42. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_compat.py +0 -0
  43. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_config.py +0 -0
  44. {plugsync_cli-0.3.0 → plugsync_cli-0.5.0}/tests/test_serializer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plugsync-cli
3
- Version: 0.3.0
3
+ Version: 0.5.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.1.0"
2
+ __version__ = "0.5.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
 
@@ -154,6 +179,19 @@ class PlugSyncClient:
154
179
  r.raise_for_status()
155
180
  return r.json()
156
181
 
182
+ def config_surface(self) -> dict:
183
+ """The config catalogue: actions, transforms, when, sources, json_schema.
184
+
185
+ `json_schema` is `ConnectorConfigV3.model_json_schema()`, defaults
186
+ included: it is how `plugsync diff` knows which values the server wrote
187
+ from a schema default rather than the operator (#1259). Org-scoped
188
+ auth only -- the catalogue itself is the same for every org.
189
+ """
190
+ with self._client() as c:
191
+ r = c.get(self._url("/config-surface"))
192
+ r.raise_for_status()
193
+ return r.json()
194
+
157
195
  def get_version(self, connector_id: str, version: int) -> dict:
158
196
  """One published config version, config included (backend VersionResponse)."""
159
197
  with self._client() as c:
@@ -175,11 +213,19 @@ class PlugSyncClient:
175
213
  r.raise_for_status()
176
214
  return r.json()
177
215
 
178
- def publish(self, connector_id: str, change_summary: str | None = None) -> dict:
216
+ def publish(
217
+ self, connector_id: str, change_summary: str | None = None, force: bool = False,
218
+ ) -> dict:
219
+ """`force` (#1367, DX-61): publish a new version even when the draft
220
+ is identical to the active one - the values a publish resolves (plugin
221
+ ARN/version, HubSpot schema) can drift without any draft edit. Unrelated
222
+ to `plugsync push --force`, which discards a concurrent DRAFT edit."""
179
223
  with self._client() as c:
180
224
  body = {}
181
225
  if change_summary:
182
226
  body["change_summary"] = change_summary
227
+ if force:
228
+ body["force"] = True
183
229
  r = c.post(self._url(f"/connectors/{connector_id}/publish"), json=body)
184
230
  r.raise_for_status()
185
231
  return r.json()
@@ -255,12 +301,38 @@ class PlugSyncClient:
255
301
  r.raise_for_status()
256
302
  return r.json()
257
303
 
258
- def plugin_invoke(self, plugin_id: str, payload: dict) -> dict:
259
- """Proxy an invocation to the plugin's Lambda function."""
304
+ def plugin_invoke(
305
+ self,
306
+ plugin_id: str,
307
+ payload: dict,
308
+ config: dict | None = None,
309
+ auth_credential: str | None = None,
310
+ auth_key: str | None = None,
311
+ ) -> dict:
312
+ """Proxy an invocation to the plugin's Lambda function.
313
+
314
+ `payload` is the event BODY only -- it lands at `event.payload` on
315
+ the Lambda side, not the whole PluginPayload envelope (issue #1365).
316
+ `config` mirrors `event.config`.
317
+
318
+ `auth_credential` is a name, never a secret in the clear: the server
319
+ reads it as an event source name and resolves that source's outbound
320
+ auth exactly as a real flow does (its credential, auth_type and
321
+ auth_header), falling back to a stored credential name when the org
322
+ has no source called that. The event.auth KEY those headers land
323
+ under is `auth_key` when given, else `auth_credential` itself; that
324
+ key is also the `auth` capability grant the server requires on the
325
+ plugin, mirroring what the runtime hands a plugin.
326
+ """
260
327
  with self._client() as c:
261
328
  r = c.post(
262
329
  self._url(f"/plugins/{plugin_id}/invoke"),
263
- json={"payload": payload},
330
+ json={
331
+ "payload": payload,
332
+ "config": config or {},
333
+ "auth_credential": auth_credential,
334
+ "auth_key": auth_key,
335
+ },
264
336
  )
265
337
  r.raise_for_status()
266
338
  return r.json()
@@ -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
+ )