plugsync-cli 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
1
  """PluSync CLI — git-like connector management."""
2
- __version__ = "0.1.0"
2
+ __version__ = "0.4.0"
plugsync_cli/client.py CHANGED
@@ -154,6 +154,19 @@ class PlugSyncClient:
154
154
  r.raise_for_status()
155
155
  return r.json()
156
156
 
157
+ def config_surface(self) -> dict:
158
+ """The config catalogue: actions, transforms, when, sources, json_schema.
159
+
160
+ `json_schema` is `ConnectorConfigV3.model_json_schema()`, defaults
161
+ included: it is how `plugsync diff` knows which values the server wrote
162
+ from a schema default rather than the operator (#1259). Org-scoped
163
+ auth only -- the catalogue itself is the same for every org.
164
+ """
165
+ with self._client() as c:
166
+ r = c.get(self._url("/config-surface"))
167
+ r.raise_for_status()
168
+ return r.json()
169
+
157
170
  def get_version(self, connector_id: str, version: int) -> dict:
158
171
  """One published config version, config included (backend VersionResponse)."""
159
172
  with self._client() as c:
@@ -175,11 +188,19 @@ class PlugSyncClient:
175
188
  r.raise_for_status()
176
189
  return r.json()
177
190
 
178
- def publish(self, connector_id: str, change_summary: str | None = None) -> dict:
191
+ def publish(
192
+ self, connector_id: str, change_summary: str | None = None, force: bool = False,
193
+ ) -> dict:
194
+ """`force` (#1367, DX-61): publish a new version even when the draft
195
+ is identical to the active one - the values a publish resolves (plugin
196
+ ARN/version, HubSpot schema) can drift without any draft edit. Unrelated
197
+ to `plugsync push --force`, which discards a concurrent DRAFT edit."""
179
198
  with self._client() as c:
180
199
  body = {}
181
200
  if change_summary:
182
201
  body["change_summary"] = change_summary
202
+ if force:
203
+ body["force"] = True
183
204
  r = c.post(self._url(f"/connectors/{connector_id}/publish"), json=body)
184
205
  r.raise_for_status()
185
206
  return r.json()
@@ -255,12 +276,38 @@ class PlugSyncClient:
255
276
  r.raise_for_status()
256
277
  return r.json()
257
278
 
258
- def plugin_invoke(self, plugin_id: str, payload: dict) -> dict:
259
- """Proxy an invocation to the plugin's Lambda function."""
279
+ def plugin_invoke(
280
+ self,
281
+ plugin_id: str,
282
+ payload: dict,
283
+ config: dict | None = None,
284
+ auth_credential: str | None = None,
285
+ auth_key: str | None = None,
286
+ ) -> dict:
287
+ """Proxy an invocation to the plugin's Lambda function.
288
+
289
+ `payload` is the event BODY only -- it lands at `event.payload` on
290
+ the Lambda side, not the whole PluginPayload envelope (issue #1365).
291
+ `config` mirrors `event.config`.
292
+
293
+ `auth_credential` is a name, never a secret in the clear: the server
294
+ reads it as an event source name and resolves that source's outbound
295
+ auth exactly as a real flow does (its credential, auth_type and
296
+ auth_header), falling back to a stored credential name when the org
297
+ has no source called that. The event.auth KEY those headers land
298
+ under is `auth_key` when given, else `auth_credential` itself; that
299
+ key is also the `auth` capability grant the server requires on the
300
+ plugin, mirroring what the runtime hands a plugin.
301
+ """
260
302
  with self._client() as c:
261
303
  r = c.post(
262
304
  self._url(f"/plugins/{plugin_id}/invoke"),
263
- json={"payload": payload},
305
+ json={
306
+ "payload": payload,
307
+ "config": config or {},
308
+ "auth_credential": auth_credential,
309
+ "auth_key": auth_key,
310
+ },
264
311
  )
265
312
  r.raise_for_status()
266
313
  return r.json()
@@ -8,20 +8,63 @@ archive -- so the local state was always empty, and an empty local state
8
8
  diffs clean against anything. Both sides now compare config blobs directly:
9
9
  the local directory as `serializer.read_config_directory` returns it, the
10
10
  published one as `GET /versions/{v}` returns it. No zip in between.
11
+
12
+ Then the opposite failure (#1259): the comparison was `json.dumps(sort_keys=
13
+ True)` per item and the answer was the literal string "differs", so a config
14
+ whose only divergence was `on_target_deleted: "skip"` -- a schema default the
15
+ server materializes on every draft write -- read as "six entities modified" to
16
+ an operator deciding whether to publish. A false six is worse than silence.
17
+
18
+ So the diff is now field-level: it names the keys that differ, with the nested
19
+ path for the ones inside `schemas`/`steps`, and it separates the fields that
20
+ exist only on the server AND hold that field's schema default. Those are the
21
+ server's doing, not the operator's, so they are reported apart and left out of
22
+ the change count. The defaults come from the schema itself (see
23
+ `plugsync_cli.schema_defaults`), never from a list maintained here.
24
+
25
+ A third bug of the same shape (#1366): a flow with a `plugin` step publishes
26
+ with `function_arn`/`version`/`capabilities` resolved and embedded into
27
+ `step["plugin"]` (`app/services/plugin_resolver.py`), fields the authored
28
+ local form never has. Compared raw, that flow reads as permanently modified,
29
+ forever, even with zero edits. So the published side's plugin steps are
30
+ normalized against the matching local step (by step id) before the field-level
31
+ diff runs, stripping exactly those fields when the local step does not
32
+ declare them -- the same normalization `publish_service.compute_diff` already
33
+ does for the dashboard's draft/version diff (#690), on the same field list,
34
+ read from the same `GET /api/config-surface` (`plugin_embedded_fields`, see
35
+ `plugsync_cli.schema_defaults.plugin_embedded_fields`). A genuine drift (both
36
+ sides declaring the field with different values, e.g. `pull --revision`'d
37
+ files diffed after a plugin re-promotion) is untouched by this and still
38
+ surfaces as a normal change.
39
+
40
+ Read-only throughout, and no diff result is a failure: whatever this command
41
+ finds -- changes, materialized defaults, nothing at all -- it exits 0, because
42
+ it describes and never gates. `plugsync validate` is the one that fails a
43
+ pipeline. A non-zero exit still comes from not being able to run at all (no
44
+ `.plugsync.yaml`, an unreadable version, an API error), exactly as before #1259:
45
+ nothing here changes which situations exit non-zero.
11
46
  """
47
+ import copy
12
48
  import json
49
+ from dataclasses import dataclass
50
+ from typing import Any
13
51
 
14
52
  import click
15
53
  from rich.console import Console
16
54
  from rich.table import Table
17
55
 
18
56
  from plugsync_cli.context import build_client, fail, load_context
57
+ from plugsync_cli.schema_defaults import SchemaNode, plugin_embedded_fields, schema_root
19
58
 
20
59
  console = Console()
21
60
 
22
61
  # Top-level config keys compared item-by-item (keyed by name) rather than whole.
23
62
  COLLECTIONS = {"entities": "entity", "flows": "flow"}
24
63
 
64
+ # Keys listed in one Detail cell before the rest is summarized. A row exists to
65
+ # tell an operator where to look, and forty paths in a table cell do not.
66
+ MAX_LISTED_KEYS = 8
67
+
25
68
 
26
69
  def _by_name(items) -> dict[str, dict]:
27
70
  return {item["name"]: item for item in items or []}
@@ -31,50 +74,308 @@ def _differs(left, right) -> bool:
31
74
  return json.dumps(left, sort_keys=True) != json.dumps(right, sort_keys=True)
32
75
 
33
76
 
34
- def _diff_configs(local: dict, remote: dict) -> list[dict]:
77
+ @dataclass(frozen=True)
78
+ class FieldChange:
79
+ """One key whose value is not the same on both sides.
80
+
81
+ `path` is written the way the config reads it -- `schemas.hubspot.
82
+ on_target_deleted`, `steps[2].action` -- so it can be followed straight
83
+ into the YAML file the row names.
84
+ """
85
+
86
+ path: str
87
+ # The published value, carried only for a server default: the row prints it
88
+ # so the operator can see it is the default and not something surprising.
89
+ value: Any
90
+ # Absent from the local files, present in the published config. A superset
91
+ # of `server_default`: a key can be missing locally for other reasons.
92
+ only_on_server: bool
93
+ server_default: bool
94
+
95
+
96
+ def _field_changes(
97
+ local: Any, remote: Any, node: SchemaNode, path: str = ""
98
+ ) -> list[FieldChange]:
99
+ """Every leaf that differs between two config fragments, named by path.
100
+
101
+ Lists are walked by index only when both sides are the same length: with a
102
+ different length there is no honest pairing (an inserted step would report
103
+ every later one as modified), so the list is reported as one change.
104
+ """
105
+ if isinstance(local, dict) and isinstance(remote, dict):
106
+ changes: list[FieldChange] = []
107
+ for key in sorted(set(local) | set(remote)):
108
+ here = f"{path}.{key}" if path else key
109
+ if key not in local:
110
+ changes.append(_absent_locally(key, remote[key], node, here))
111
+ elif key not in remote:
112
+ changes.append(_changed(here))
113
+ else:
114
+ changes.extend(
115
+ _field_changes(local[key], remote[key], node.child(key), here)
116
+ )
117
+ return changes
118
+
119
+ if isinstance(local, list) and isinstance(remote, list) and len(local) == len(remote):
120
+ member = node.item()
121
+ changes = []
122
+ for index, (mine, theirs) in enumerate(zip(local, remote)):
123
+ changes.extend(_field_changes(mine, theirs, member, f"{path}[{index}]"))
124
+ return changes
125
+
126
+ if _differs(local, remote):
127
+ return [_changed(path)]
128
+ return []
129
+
130
+
131
+ def _absent_locally(key: str, value: Any, node: SchemaNode, path: str) -> FieldChange:
132
+ """A key the published config has and the local files do not."""
133
+ return FieldChange(path, value, True, node.is_default(key, value))
134
+
135
+
136
+ def _changed(path: str) -> FieldChange:
137
+ """A difference that is the operator's own: an edited or dropped value."""
138
+ return FieldChange(path, None, False, False)
139
+
140
+
141
+ def _compact(value: Any) -> str:
142
+ try:
143
+ return json.dumps(value, sort_keys=True)
144
+ except (TypeError, ValueError):
145
+ return repr(value)
146
+
147
+
148
+ def _join(parts: list[str]) -> str:
149
+ if len(parts) <= MAX_LISTED_KEYS:
150
+ return ", ".join(parts)
151
+ listed = ", ".join(parts[:MAX_LISTED_KEYS])
152
+ return f"{listed}, and {len(parts) - MAX_LISTED_KEYS} more"
153
+
154
+
155
+ def _rows(prefix: str, scope: str, fields: list[FieldChange]) -> list[dict]:
156
+ """Up to two rows for one item: the operator's changes, and the server's.
157
+
158
+ Split rather than merged so an item that has both -- a real edit plus a
159
+ materialized default -- shows the edit on its own line and in the count,
160
+ with the default visible but not inflating it.
161
+ """
162
+ mine = [field for field in fields if not field.server_default]
163
+ theirs = [field for field in fields if field.server_default]
164
+ rows = []
165
+ if mine:
166
+ rows.append(
167
+ {
168
+ "type": f"{prefix}_modified",
169
+ "scope": scope,
170
+ "detail": _join([field.path for field in mine]),
171
+ "fields": len(mine),
172
+ "server_default": False,
173
+ # Whether naming this row correctly depended on knowing the
174
+ # schema: with the schema unavailable, some of these keys may
175
+ # be server defaults reported as the operator's changes.
176
+ "needs_schema": any(field.only_on_server for field in mine),
177
+ }
178
+ )
179
+ if theirs:
180
+ rows.append(
181
+ {
182
+ "type": f"{prefix}_server_default",
183
+ "scope": scope,
184
+ "detail": _join(
185
+ [f"{field.path} = {_compact(field.value)}" for field in theirs]
186
+ ),
187
+ "fields": len(theirs),
188
+ "server_default": True,
189
+ "needs_schema": False,
190
+ }
191
+ )
192
+ return rows
193
+
194
+
195
+ def _diff_configs(local: dict, remote: dict, root: SchemaNode) -> list[dict]:
35
196
  """Changes that turn `remote` into `local`, one row per named item or key."""
36
197
  changes: list[dict] = []
37
198
 
38
199
  for key, label in COLLECTIONS.items():
200
+ member = root.child(key).item()
39
201
  local_items, remote_items = _by_name(local.get(key)), _by_name(remote.get(key))
40
202
  for name in sorted(set(local_items) | set(remote_items)):
41
203
  if name not in remote_items:
42
- changes.append({"type": f"{label}_added", "scope": name, "detail": "only in local files"})
204
+ changes.append(_simple(f"{label}_added", name, "only in local files"))
43
205
  elif name not in local_items:
44
- changes.append({"type": f"{label}_removed", "scope": name, "detail": "only in the published version"})
45
- elif _differs(local_items[name], remote_items[name]):
46
- changes.append({"type": f"{label}_modified", "scope": name, "detail": "differs"})
206
+ changes.append(
207
+ _simple(f"{label}_removed", name, "only in the published version")
208
+ )
209
+ else:
210
+ changes.extend(
211
+ _rows(
212
+ label,
213
+ name,
214
+ _field_changes(local_items[name], remote_items[name], member),
215
+ )
216
+ )
47
217
 
48
218
  scalar_keys = (set(local) | set(remote)) - set(COLLECTIONS)
49
219
  for key in sorted(scalar_keys):
50
- if _differs(local.get(key), remote.get(key)):
51
- changes.append({"type": f"{key}_modified", "scope": key, "detail": "differs"})
220
+ if key not in local:
221
+ fields = [_absent_locally(key, remote[key], root, key)]
222
+ elif key not in remote:
223
+ fields = [_changed(key)]
224
+ else:
225
+ fields = _field_changes(local[key], remote[key], root.child(key), key)
226
+ changes.extend(_rows(key, key, fields))
52
227
 
53
228
  return changes
54
229
 
55
230
 
231
+ def _simple(change_type: str, scope: str, detail: str) -> dict:
232
+ return {
233
+ "type": change_type,
234
+ "scope": scope,
235
+ "detail": detail,
236
+ "fields": 1,
237
+ "server_default": False,
238
+ "needs_schema": False,
239
+ }
240
+
241
+
56
242
  def _local_item_count(config: dict) -> int:
57
243
  collections = sum(len(config.get(key) or []) for key in COLLECTIONS)
58
244
  scalars = sum(1 for key, value in config.items() if key not in COLLECTIONS and value)
59
245
  return collections + scalars
60
246
 
61
247
 
62
- def _render(changes: list[dict]) -> None:
63
- console.print(f"\n[bold]{len(changes)} change{'' if len(changes) == 1 else 's'} detected[/bold]\n")
248
+ def _mark(change_type: str) -> str:
249
+ if change_type.endswith("_added"):
250
+ return f"[green]+ {change_type}[/green]"
251
+ if change_type.endswith("_removed"):
252
+ return f"[red]- {change_type}[/red]"
253
+ if change_type.endswith("_server_default"):
254
+ return f"[cyan]= {change_type}[/cyan]"
255
+ return f"[yellow]~ {change_type}[/yellow]"
256
+
257
+
258
+ def _table(changes: list[dict]) -> Table:
64
259
  table = Table(show_header=True, header_style="bold")
65
- table.add_column("Change", width=24)
260
+ table.add_column("Change", width=26)
66
261
  table.add_column("Item")
67
- table.add_column("Detail")
262
+ # fold: a Detail cell now carries dotted key paths, and a path is one word
263
+ # to a word-wrapper. Ellipsizing it would drop the very end -- the key
264
+ # name -- which is the part this column exists to show.
265
+ table.add_column("Detail", overflow="fold")
68
266
  for change in changes:
69
- change_type = change["type"]
70
- if change_type.endswith("_added"):
71
- rendered = f"[green]+ {change_type}[/green]"
72
- elif change_type.endswith("_removed"):
73
- rendered = f"[red]- {change_type}[/red]"
74
- else:
75
- rendered = f"[yellow]~ {change_type}[/yellow]"
76
- table.add_row(rendered, change["scope"], change["detail"])
77
- console.print(table)
267
+ table.add_row(_mark(change["type"]), change["scope"], change["detail"])
268
+ return table
269
+
270
+
271
+ def _render(changes: list[dict], revision: int) -> None:
272
+ mine = [change for change in changes if not change["server_default"]]
273
+ defaults = [change for change in changes if change["server_default"]]
274
+
275
+ if mine:
276
+ console.print(
277
+ f"\n[bold]{len(mine)} change{'' if len(mine) == 1 else 's'} detected[/bold]\n"
278
+ )
279
+ console.print(_table(mine))
280
+ else:
281
+ console.print(
282
+ f"[green]No changes of yours. Local files match published "
283
+ f"v{revision}.[/green]"
284
+ )
285
+
286
+ if defaults:
287
+ total = sum(change["fields"] for change in defaults)
288
+ console.print(
289
+ f"\n[bold]{total} field{'' if total == 1 else 's'} the server filled "
290
+ "in from a schema default[/bold], absent from your files: written on "
291
+ "every draft save, not changes you made\n"
292
+ )
293
+ console.print(_table(defaults))
294
+
295
+
296
+ def _config_surface(client) -> dict:
297
+ """The `/api/config-surface` catalog, or `{}` if it cannot be had.
298
+
299
+ Deliberately swallows everything: this command must keep working against a
300
+ backend with no `/api/config-surface`, one that answers it with something
301
+ unexpected, or none at all. Fetched exactly once per run and shared by both
302
+ consumers that need it -- the JSON Schema (`schema_root`) and the plugin
303
+ embedded-field list (`plugin_embedded_fields`) -- so a diff against an
304
+ unreachable API still costs one GET, not two.
305
+ """
306
+ try:
307
+ surface = client.config_surface()
308
+ return surface if isinstance(surface, dict) else {}
309
+ except Exception:
310
+ return {}
311
+
312
+
313
+ def _strip_plugin_embedded_fields(
314
+ remote_flow: dict, local_flow: dict | None, fields: frozenset[str]
315
+ ) -> dict:
316
+ """A deep copy of `remote_flow` with publish-materialized plugin
317
+ coordinates dropped from any `action == "plugin"` step whose `local_flow`
318
+ counterpart (matched by step id) does not declare them (#1366).
319
+
320
+ Mirrors `publish_service._normalize_plugin_step_fields` (#690), the
321
+ backend's own fix for the same bug shape one level up (draft vs active
322
+ version instead of local files vs published version). Scoped strictly to
323
+ `step["plugin"]` on `action == "plugin"` steps and matched by step id, so a
324
+ real drift -- both sides declaring the field, with different values --
325
+ still surfaces as a normal change.
326
+ """
327
+ if not fields:
328
+ return remote_flow
329
+ local_steps_by_id = {
330
+ s["id"]: s
331
+ for s in (local_flow or {}).get("steps", []) or []
332
+ if isinstance(s, dict) and s.get("id")
333
+ }
334
+ normalized = copy.deepcopy(remote_flow)
335
+ for step in normalized.get("steps", []) or []:
336
+ if step.get("action") != "plugin" or not step.get("id"):
337
+ continue
338
+ plugin_block = step.get("plugin")
339
+ if not isinstance(plugin_block, dict):
340
+ continue
341
+ local_step = local_steps_by_id.get(step["id"]) or {}
342
+ local_plugin_block = local_step.get("plugin")
343
+ if not isinstance(local_plugin_block, dict):
344
+ local_plugin_block = {}
345
+ for field_name in fields:
346
+ if field_name in plugin_block and field_name not in local_plugin_block:
347
+ del plugin_block[field_name]
348
+ return normalized
349
+
350
+
351
+ def _normalize_remote_flows(
352
+ remote_flows: list, local_flows: list, fields: frozenset[str]
353
+ ) -> list:
354
+ local_by_name = {f["name"]: f for f in local_flows or [] if isinstance(f, dict)}
355
+ return [
356
+ _strip_plugin_embedded_fields(f, local_by_name.get(f.get("name")), fields)
357
+ for f in remote_flows or []
358
+ if isinstance(f, dict)
359
+ ]
360
+
361
+
362
+ def _warn_defaults_unknown(changes: list[dict]) -> None:
363
+ """Say why a change may not be one, but only when the doubt is real.
364
+
365
+ Fires only if the schema was unavailable AND something differs solely by
366
+ being absent locally: that is the shape a materialized default has. Any
367
+ other diff is unaffected by the missing schema and must not drag a warning
368
+ an operator would learn to skip.
369
+ """
370
+ if not any(change["needs_schema"] for change in changes):
371
+ return
372
+ console.print(
373
+ "\n[yellow]Some keys below exist only in the published version. This "
374
+ "API does not serve the config schema (GET /api/config-surface), so "
375
+ "the ones the server materialized from a schema default cannot be told "
376
+ "apart from your own edits. Upgrade the API to see them separated."
377
+ "[/yellow]"
378
+ )
78
379
 
79
380
 
80
381
  @click.command()
@@ -92,6 +393,10 @@ def diff(path: str | None, revision: int | None):
92
393
  Read-only: nothing is uploaded. To see what a publish would change instead,
93
394
  use `plugsync preview`, which diffs the DRAFT against the active version.
94
395
 
396
+ Each modified item names the keys that differ. Keys the server wrote from a
397
+ schema default (`on_target_deleted`, `pipelines`, ...) are listed
398
+ separately and excluded from the change count: they are not your edits.
399
+
95
400
  Examples:
96
401
  plugsync diff
97
402
  plugsync diff ./hubspot-juve
@@ -115,8 +420,20 @@ def diff(path: str | None, revision: int | None):
115
420
  fail(f"Version {revision} carries no readable config.")
116
421
 
117
422
  console.print(f"Comparing local files against published v{revision}...")
118
- changes = _diff_configs(local_config, remote_config)
423
+ surface = _config_surface(client)
424
+ root = schema_root(surface.get("json_schema"))
425
+ fields = plugin_embedded_fields(surface)
426
+ if isinstance(remote_config.get("flows"), list):
427
+ remote_config = {
428
+ **remote_config,
429
+ "flows": _normalize_remote_flows(
430
+ remote_config["flows"], local_config.get("flows"), fields
431
+ ),
432
+ }
433
+ changes = _diff_configs(local_config, remote_config, root)
119
434
  if not changes:
120
435
  console.print(f"[green]No changes. Local files match published v{revision}.[/green]")
121
436
  return
122
- _render(changes)
437
+ _render(changes, revision)
438
+ if not root.known:
439
+ _warn_defaults_unknown(changes)
@@ -232,7 +232,29 @@ def plugin_push(path: str | None):
232
232
  result = client.plugin_push(plugin_id, bundle_bytes, source_code)
233
233
  except httpx.HTTPStatusError as exc:
234
234
  _handle_plugin_api_error(exc)
235
- console.print(f"[green]Pushed - status: {result.get('status', '?')}[/green]")
235
+
236
+ # `result["status"]` is the plugin's PROMOTION status (draft/dev/staging/live/...),
237
+ # not the outcome of this push -- push never writes Plugin.status itself
238
+ # (backend/app/api/plugins.py's push_plugin only stores the bundle and enqueues a
239
+ # deploy_plugin_dev outbox row). Reporting it bare as "Pushed - status: live" reads
240
+ # as "this push went live", which is false whenever the plugin was already promoted
241
+ # before this push (issue #1364/DX-58). Spell out what actually happened instead.
242
+ live_str = result.get("live_version") or "none"
243
+ staging_str = result.get("staging_version") or "none"
244
+
245
+ # Kept as short, separate print() calls on purpose: Rich wraps a single long
246
+ # logical line at the terminal width (80 cols when not a tty), which would
247
+ # otherwise fold a real plugin_id (a 36-char UUID) mid-command and make the
248
+ # printed `plugsync plugin status <id>` hint uncopy-pasteable.
249
+ console.print("[green]Bundle uploaded, dev deploy enqueued.[/green]")
250
+ console.print(
251
+ f"Promotion status: {result.get('status', '?')} "
252
+ f"(live_version {live_str}, staging_version {staging_str})."
253
+ )
254
+ console.print("Run `plugsync plugin promote` to ship it.")
255
+ console.print("Dev deploy runs asynchronously; it may take a few seconds.")
256
+ console.print(f"Check outcome: `plugsync plugin status {plugin_id}`")
257
+ console.print(f"or `plugsync plugin logs {plugin_id}`.")
236
258
 
237
259
 
238
260
  # ---------------------------------------------------------------------------
@@ -331,10 +353,15 @@ def plugin_status_cmd(plugin_id: str):
331
353
 
332
354
  info = client.plugin_status(plugin_id)
333
355
  console.print(f"[bold]{info.get('name', plugin_id)}[/bold]")
334
- console.print(f" id: {info.get('id', plugin_id)}")
335
- console.print(f" status: {info.get('status', '?')}")
356
+ console.print(f" id: {info.get('id', plugin_id)}")
357
+ console.print(f" status: {info.get('status', '?')}")
358
+ console.print(f" staging_version: {info.get('staging_version') or 'none'}")
359
+ console.print(f" live_version: {info.get('live_version') or 'none'}")
360
+ console.print(f" function_arn: {info.get('function_arn') or 'none'}")
361
+ if info.get("error_message"):
362
+ console.print(f" error_message: {info['error_message']}")
336
363
  if info.get("bundle_hash"):
337
- console.print(f" hash: {info['bundle_hash'][:12]}...")
364
+ console.print(f" hash: {info['bundle_hash'][:12]}...")
338
365
 
339
366
 
340
367
  # ---------------------------------------------------------------------------
@@ -422,13 +449,49 @@ def plugin_metrics_cmd(plugin_id: str, window: str):
422
449
  "--payload",
423
450
  "-p",
424
451
  default="{}",
425
- help='JSON payload to send (default: "{}").',
452
+ help=(
453
+ 'JSON event BODY to send (default: "{}"). This is only the '
454
+ "`payload` key of the PluginPayload your handler receives -- it "
455
+ "lands at event.payload, NOT the whole envelope. Use --config and "
456
+ "--auth for the other keys a plugin can read."
457
+ ),
426
458
  )
427
- def plugin_invoke_cmd(plugin_id: str, payload: str):
459
+ @click.option(
460
+ "--config",
461
+ "-c",
462
+ default=None,
463
+ help=(
464
+ "JSON object to send as event.config, mirroring a step's authored "
465
+ "`plugin.config` (default: {})."
466
+ ),
467
+ )
468
+ @click.option(
469
+ "--auth",
470
+ "-a",
471
+ "auth",
472
+ default=None,
473
+ help=(
474
+ "Name of an event source (NOT a secret) whose outbound auth the "
475
+ "server resolves into headers, exactly as a real flow resolves it: "
476
+ "the source's own credential, auth_type and auth_header. The "
477
+ "headers land at event.auth[<source-name>], the key a real `auth` "
478
+ "capability grant uses, and the plugin must declare that grant "
479
+ "(`auth:<source-name>`) or the invoke is refused. Two more forms: "
480
+ "`<credential-name>`, used when no source of your org carries that "
481
+ "name, resolves a stored credential with the default header for "
482
+ "its type; `<source-name>=<credential-name>` pins that credential "
483
+ "onto the named source's header shape."
484
+ ),
485
+ )
486
+ def plugin_invoke_cmd(plugin_id: str, payload: str, config: str | None, auth: str | None):
428
487
  """Invoke a plugin with a test payload.
429
488
 
430
489
  Example:
431
490
  plugsync plugin invoke <id> --payload '{"key": "value"}'
491
+ plugsync plugin invoke <id> --payload '{"key": "value"}' \\
492
+ --config '{"doc_type": "invoice"}' --auth kvk
493
+ plugsync plugin invoke <id> --payload '{"key": "value"}' \\
494
+ --auth kvk=erp_api_key
432
495
  """
433
496
  try:
434
497
  payload_dict = json.loads(payload)
@@ -436,13 +499,37 @@ def plugin_invoke_cmd(plugin_id: str, payload: str):
436
499
  console.print(f"[red]Invalid JSON payload: {e}[/red]")
437
500
  raise SystemExit(1)
438
501
 
502
+ config_dict: dict = {}
503
+ if config is not None:
504
+ try:
505
+ config_dict = json.loads(config)
506
+ except json.JSONDecodeError as e:
507
+ console.print(f"[red]Invalid JSON config: {e}[/red]")
508
+ raise SystemExit(1)
509
+
510
+ # `--auth <name>` (a source name, falling back server-side to a credential
511
+ # name when the org has no source called that) or `--auth <source>=<cred>`
512
+ # (an explicit credential pinned onto that source's header shape). Either
513
+ # way the part before the "=" -- or the whole value -- is the event.auth
514
+ # key AND the `auth` capability grant the server requires.
515
+ auth_credential = auth
516
+ auth_key: str | None = None
517
+ if auth is not None and "=" in auth:
518
+ auth_key, auth_credential = auth.split("=", 1)
519
+ if not auth_key or not auth_credential:
520
+ console.print(
521
+ "[red]Invalid --auth: expected <source-name>=<credential-name>, "
522
+ f"got {auth!r}[/red]"
523
+ )
524
+ raise SystemExit(1)
525
+
439
526
  try:
440
527
  client = PlugSyncClient()
441
528
  except RuntimeError as e:
442
529
  console.print(f"[red]{e}[/red]")
443
530
  raise SystemExit(1)
444
531
 
445
- result = client.plugin_invoke(plugin_id, payload_dict)
532
+ result = client.plugin_invoke(plugin_id, payload_dict, config_dict, auth_credential, auth_key)
446
533
  console.print(json.dumps(result, indent=2))
447
534
 
448
535
 
@@ -39,7 +39,26 @@ console = Console()
39
39
  default=False,
40
40
  help="Overwrite the remote draft even if it changed since you pulled",
41
41
  )
42
- def push(path: str | None, message: str | None, do_publish: bool, no_publish: bool, force: bool):
42
+ @click.option(
43
+ "--republish",
44
+ is_flag=True,
45
+ default=False,
46
+ help=(
47
+ "With --publish: publish a new version even if the draft already "
48
+ "matches the active one, to refresh values a publish resolves (e.g. "
49
+ "a plugin promoted to a new live version, a HubSpot schema drifted "
50
+ "since the last publish). Unrelated to --force, which is about a "
51
+ "concurrent DRAFT edit, not the active version."
52
+ ),
53
+ )
54
+ def push(
55
+ path: str | None,
56
+ message: str | None,
57
+ do_publish: bool,
58
+ no_publish: bool,
59
+ force: bool,
60
+ republish: bool,
61
+ ):
43
62
  """Upload a local connector directory to its draft.
44
63
 
45
64
  Nothing is published unless you pass --publish. Without it the config lands
@@ -50,9 +69,12 @@ def push(path: str | None, message: str | None, do_publish: bool, no_publish: bo
50
69
  plugsync push ./hubspot-juve # push a specific directory
51
70
  plugsync push --publish -m "Added phone"
52
71
  plugsync push --force # discard a concurrent draft edit
72
+ plugsync push --publish --republish # re-publish an identical draft
53
73
  """
54
74
  if do_publish and no_publish:
55
75
  fail("--publish and --no-publish contradict each other; pick one.")
76
+ if republish and not do_publish:
77
+ fail("--republish only makes sense together with --publish.")
56
78
  if no_publish:
57
79
  console.print(
58
80
  "[yellow]--no-publish is deprecated: push is draft-only by default. "
@@ -85,10 +107,10 @@ def push(path: str | None, message: str | None, do_publish: bool, no_publish: bo
85
107
  )
86
108
  return
87
109
 
88
- _publish(client, context.connector_id, message)
110
+ _publish(client, context.connector_id, message, republish=republish)
89
111
 
90
112
 
91
- def _publish(client, connector_id: str, message: str | None) -> None:
113
+ def _publish(client, connector_id: str, message: str | None, republish: bool = False) -> None:
92
114
  console.print("Running preflight before publishing...")
93
115
  error_count = render_preflight(console, client.preflight(connector_id))
94
116
  if error_count:
@@ -97,12 +119,19 @@ def _publish(client, connector_id: str, message: str | None) -> None:
97
119
  "published. Fix the errors and push again."
98
120
  )
99
121
 
100
- result = client.publish(connector_id, change_summary=message)
122
+ result = client.publish(connector_id, change_summary=message, force=republish)
101
123
  if result.get("no_changes"):
102
124
  console.print(
103
125
  f"[yellow]Nothing to publish: v{result.get('version')} already "
104
126
  "matches the draft.[/yellow]"
105
127
  )
128
+ console.print(
129
+ "The draft is identical to the active version. If the values a "
130
+ "publish RESOLVES have since changed - a plugin promoted to a new "
131
+ "live version, a credential rebind, HubSpot schema provisioning "
132
+ "that needs to re-run - re-run with [bold]--publish --republish[/bold] "
133
+ "to force a new version anyway."
134
+ )
106
135
  return
107
136
  console.print(f"[green]Published v{result.get('version')}[/green]")
108
137
  warnings = result.get("warnings") or []
plugsync_cli/context.py CHANGED
@@ -15,6 +15,7 @@ from plugsync_cli.client import DraftConflict, PlugSyncClient
15
15
  from plugsync_cli.serializer import (
16
16
  CONFIG_FILE_NAME,
17
17
  ConfigDirectoryError,
18
+ Normalization,
18
19
  canonical_config,
19
20
  flows_sharing_a_trigger,
20
21
  read_config_directory,
@@ -37,6 +38,14 @@ def fail(message: str) -> NoReturn:
37
38
  raise SystemExit(1)
38
39
 
39
40
 
41
+ def warn_normalized(note: Normalization) -> None:
42
+ """Say that a local value did not reach the API as it was written (#1254).
43
+
44
+ soft_wrap for the same reason `fail` uses it: the note carries a file path.
45
+ """
46
+ console.print(f"[yellow]{note}[/yellow]", soft_wrap=True)
47
+
48
+
40
49
  def build_client() -> PlugSyncClient:
41
50
  """A configured client, or a clean exit explaining what is missing."""
42
51
  try:
@@ -59,7 +68,7 @@ class ConnectorContext:
59
68
 
60
69
  def config(self) -> dict:
61
70
  try:
62
- return read_config_directory(self.directory)
71
+ return read_config_directory(self.directory, report=warn_normalized)
63
72
  except ConfigDirectoryError as exc:
64
73
  fail(str(exc))
65
74
 
@@ -0,0 +1,183 @@
1
+ """Walk the connector-config JSON Schema to answer "is this value a default?".
2
+
3
+ Why this exists (issue #1259): the backend validates every draft write and
4
+ stores the canonicalized result, which materializes the schema defaults the
5
+ input omitted -- `on_target_deleted: "skip"`, `pipelines: []`, `settings: {}`.
6
+ A hand-written connector directory does not carry them, so `plugsync diff`
7
+ reported six modified entities to an operator who had changed nothing.
8
+
9
+ Telling those apart needs the defaults, and the defaults have to come FROM the
10
+ schema: this package ships on PyPI on its own, cannot import `app.*`, and a
11
+ table of defaults copied into the client would be a second source of truth free
12
+ to drift from the models the server actually validates with. So the schema is
13
+ read at runtime off `GET /api/config-surface`, whose `json_schema` is
14
+ `ConnectorConfigV3.model_json_schema()`, and this module navigates it.
15
+
16
+ Everything here is a null object: an unknown position answers "no properties,
17
+ no default" rather than `None`, and `schema_root(None)` is a perfectly usable
18
+ root that simply knows no defaults. That is what makes the degradation free --
19
+ against a backend with no `/api/config-surface`, or an unreachable one, the
20
+ walker keeps working and every difference is reported as the caller's own,
21
+ which is exactly the pre-#1259 behaviour.
22
+ """
23
+ import json
24
+ from typing import Any
25
+
26
+ # Distinct from `None`: `None` is itself a declared default all over this
27
+ # schema (every `X | None = None` field), so "no default declared" needs a
28
+ # value that cannot collide with one.
29
+ MISSING = object()
30
+
31
+ # Guards a `$ref` cycle. The schema is a tree today; a self-referential model
32
+ # (a pipeline holding pipelines) would otherwise spin here forever.
33
+ _MAX_REF_HOPS = 32
34
+
35
+
36
+ def _same(left: Any, right: Any) -> bool:
37
+ """Whether two JSON values are the same document, key order aside."""
38
+ try:
39
+ return json.dumps(left, sort_keys=True) == json.dumps(right, sort_keys=True)
40
+ except (TypeError, ValueError):
41
+ return False
42
+
43
+
44
+ class SchemaNode:
45
+ """One position in the config, and what the schema declares about it.
46
+
47
+ Navigation mirrors the shape of the config document, not of the schema:
48
+ `child(key)` for a mapping key (a model field, or a value of a
49
+ `dict[str, Model]`), `item()` for a member of a list. Both always return a
50
+ node, empty when the schema says nothing about that position.
51
+ """
52
+
53
+ __slots__ = ("_schema", "_defs")
54
+
55
+ def __init__(self, schema: dict | None, defs: dict):
56
+ self._schema = schema if isinstance(schema, dict) else {}
57
+ self._defs = defs
58
+
59
+ @property
60
+ def known(self) -> bool:
61
+ """Whether the schema actually describes this position.
62
+
63
+ False for the root built from a schema that could not be fetched, which
64
+ is how a caller tells "no defaults declared here" from "no schema at
65
+ all" and can say so instead of silently reporting more changes.
66
+ """
67
+ return bool(self._schema)
68
+
69
+ # -- navigation ---------------------------------------------------------
70
+
71
+ def child(self, key: str) -> "SchemaNode":
72
+ """The node for a mapping key: a declared property, or a dict value."""
73
+ properties = self._schema.get("properties")
74
+ if isinstance(properties, dict) and key in properties:
75
+ return self._node(properties[key])
76
+ # `dict[str, EntitySchemaSpec]`: the key is free-form ("hubspot"), the
77
+ # value's shape is declared once under additionalProperties.
78
+ extra = self._schema.get("additionalProperties")
79
+ if isinstance(extra, dict):
80
+ return self._node(extra)
81
+ return SchemaNode({}, self._defs)
82
+
83
+ def item(self) -> "SchemaNode":
84
+ """The node for a member of this list."""
85
+ return self._node(self._schema.get("items"))
86
+
87
+ # -- defaults -----------------------------------------------------------
88
+
89
+ def default_for(self, key: str) -> Any:
90
+ """The default declared for `key` here, or `MISSING`."""
91
+ properties = self._schema.get("properties")
92
+ if not isinstance(properties, dict):
93
+ return MISSING
94
+ declared = properties.get(key)
95
+ if not isinstance(declared, dict) or "default" not in declared:
96
+ return MISSING
97
+ return declared["default"]
98
+
99
+ def is_default(self, key: str, value: Any) -> bool:
100
+ """Whether `value` is exactly what the schema defaults `key` to."""
101
+ default = self.default_for(key)
102
+ return default is not MISSING and _same(default, value)
103
+
104
+ # -- internals ----------------------------------------------------------
105
+
106
+ def _node(self, schema: Any) -> "SchemaNode":
107
+ return SchemaNode(self._resolve(schema), self._defs)
108
+
109
+ def _resolve(self, schema: Any) -> dict:
110
+ """Follow `$ref` and unwrap `Optional[T]` down to a schema with fields.
111
+
112
+ Pydantic writes a model-typed field as `{"$ref": "#/$defs/EntityV3"}`
113
+ and an optional one as `{"anyOf": [{"$ref": ...}, {"type": "null"}],
114
+ "default": null}`, so neither carries `properties` where the walker
115
+ needs them. A union with more than one non-null branch is given up on:
116
+ which branch a concrete value belongs to is a validation question, and
117
+ guessing it would attribute one member's defaults to another's values.
118
+ """
119
+ for _ in range(_MAX_REF_HOPS):
120
+ if not isinstance(schema, dict):
121
+ return {}
122
+ ref = schema.get("$ref")
123
+ if isinstance(ref, str):
124
+ schema = self._deref(ref)
125
+ continue
126
+ branches = schema.get("anyOf") or schema.get("oneOf")
127
+ if isinstance(branches, list):
128
+ candidates = [
129
+ branch
130
+ for branch in branches
131
+ if isinstance(branch, dict) and branch.get("type") != "null"
132
+ ]
133
+ if len(candidates) != 1:
134
+ return {}
135
+ schema = candidates[0]
136
+ continue
137
+ return schema
138
+ return {}
139
+
140
+ def _deref(self, ref: str) -> dict:
141
+ prefix = "#/$defs/"
142
+ if not ref.startswith(prefix):
143
+ return {}
144
+ return self._defs.get(ref[len(prefix):], {})
145
+
146
+
147
+ def schema_root(json_schema: dict | None) -> SchemaNode:
148
+ """The root node of a connector-config JSON Schema.
149
+
150
+ Anything other than a schema object -- `None`, a stub left by an endpoint
151
+ that answered something unexpected -- gives an empty root, which knows no
152
+ defaults and reports `known == False`.
153
+ """
154
+ schema = json_schema if isinstance(json_schema, dict) else {}
155
+ defs = schema.get("$defs")
156
+ return SchemaNode(schema, defs if isinstance(defs, dict) else {})
157
+
158
+
159
+ def plugin_embedded_fields(config_surface: dict | None) -> frozenset[str]:
160
+ """Field names a publish materializes into a plugin step's `plugin` block
161
+ (`function_arn`, `version`, `capabilities`) that the authored draft form
162
+ never declares (issue #1366).
163
+
164
+ Read from `GET /api/config-surface`'s `plugin_embedded_fields`, the same
165
+ single source of truth `publish_service.compute_diff` reads server-side
166
+ for the dashboard's draft/version diff (#690) -- never a list copied here,
167
+ for the reason `schema_root` above already gives: this package cannot
168
+ import `app.*`, and a copy is free to drift from what the server actually
169
+ embeds.
170
+
171
+ Same null-object degradation as `schema_root`: an absent/malformed value
172
+ (an older backend with no `plugin_embedded_fields`, or a `config_surface`
173
+ that could not be fetched at all) answers "nothing known", which strips
174
+ nothing -- exactly the pre-#1366 behaviour, so a plugin step's
175
+ server-resolved coordinates go back to reading as the operator's own
176
+ change instead of crashing the command.
177
+ """
178
+ if not isinstance(config_surface, dict):
179
+ return frozenset()
180
+ fields = config_surface.get("plugin_embedded_fields")
181
+ if not isinstance(fields, list):
182
+ return frozenset()
183
+ return frozenset(f for f in fields if isinstance(f, str))
@@ -39,7 +39,15 @@ That is the only case where the order is observable, so it is the case
39
39
  `flows_sharing_a_trigger` reports and `pull`/`push` warn about, rather than
40
40
  being encoded on disk: an explicit order file would be a second source of
41
41
  truth for the flow list, free to drift from the files actually present.
42
+
43
+ The config is a JSON document, so reading is also where a file stops being
44
+ "whatever YAML built" and becomes "something JSON can carry" -- see
45
+ `_ConfigLoader` and `_json_value` (issue #1254).
42
46
  """
47
+ from collections.abc import Callable
48
+ from dataclasses import dataclass
49
+ from datetime import date, datetime, time
50
+ from math import isfinite
43
51
  from pathlib import Path
44
52
 
45
53
  import yaml
@@ -64,6 +72,186 @@ class ConfigDirectoryError(Exception):
64
72
  """The local directory is not a readable connector config."""
65
73
 
66
74
 
75
+ _TIMESTAMP_TAG = "tag:yaml.org,2002:timestamp"
76
+
77
+
78
+ class _ConfigLoader(yaml.SafeLoader):
79
+ """`SafeLoader` without the implicit timestamp resolver.
80
+
81
+ A connector directory is meant to be edited by hand, and YAML 1.1 resolves
82
+ an unquoted `2026-07-26` to `datetime.date`. The config is a JSON document:
83
+ `push` PUTs it as JSON and `diff` compares two of them with `json.dumps`, so
84
+ that date used to end both commands in a `TypeError` raised from inside
85
+ httpx, naming no file and no key (issue #1254).
86
+
87
+ Dropping the resolver rather than converting the date afterwards is what
88
+ makes the common case lossless. `str(date(2026, 7, 26))` happens to give the
89
+ characters back, but `2026-07-26T10:30:00Z` would come back as
90
+ "2026-07-26 10:30:00+00:00" -- a value nobody typed. Unresolved, the scalar
91
+ stays the text on disk, whatever shape it has.
92
+
93
+ The dumper still quotes such scalars on the way out (`yaml.safe_dump` uses
94
+ the full resolver set), so a file this CLI writes keeps reading as a string
95
+ for every other YAML tool too.
96
+ """
97
+
98
+
99
+ # Rebuilt on the subclass only: `yaml_implicit_resolvers` lives on PyYAML's
100
+ # shared `BaseResolver`, and mutating it in place would silently un-quote the
101
+ # dumper too, which is what keeps a file this CLI writes unambiguous.
102
+ _ConfigLoader.yaml_implicit_resolvers = {
103
+ first_char: [(tag, regexp) for tag, regexp in resolvers if tag != _TIMESTAMP_TAG]
104
+ for first_char, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
105
+ }
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class Normalization:
110
+ """A value the reader had to change to make the config representable as JSON.
111
+
112
+ Only an explicitly tagged value can produce one: with the timestamp resolver
113
+ gone, nothing an operator types by accident is normalized. Reported rather
114
+ than applied in silence -- a config quietly holding a value other than the
115
+ one in the file is how a directory stops being the source of truth.
116
+ """
117
+
118
+ source: str
119
+ path: str
120
+ detail: str
121
+
122
+ def __str__(self) -> str:
123
+ return f"{self.source}: {self.path} {self.detail}"
124
+
125
+
126
+ # What `json.dumps` writes for the object keys it accepts without complaint.
127
+ # Reproduced rather than deferred to, so a bool key and a date key -- one of
128
+ # which `json.dumps` coerces and the other of which it rejects -- come out of
129
+ # here the same way, both reported.
130
+ _KEY_LITERALS = {True: "true", False: "false", None: "null"}
131
+
132
+ Report = Callable[[Normalization], None]
133
+
134
+
135
+ def _ignore(_: Normalization) -> None:
136
+ """Drop normalization notes: for callers that only want the config."""
137
+
138
+
139
+ def _child(path: str, key: str) -> str:
140
+ return f"{path}.{key}" if path else key
141
+
142
+
143
+ def _json_key(key, *, source: str, path: str, report: Report) -> str:
144
+ """A mapping key as the string a JSON object has to be keyed by."""
145
+ if isinstance(key, str):
146
+ return key
147
+ if isinstance(key, bool) or key is None:
148
+ text = _KEY_LITERALS[key]
149
+ elif isinstance(key, (int, float)):
150
+ text = repr(key)
151
+ elif isinstance(key, (datetime, date, time)):
152
+ text = key.isoformat()
153
+ else:
154
+ raise ConfigDirectoryError(
155
+ f"{source}: the mapping at {path or 'the document root'} is keyed by "
156
+ f"a {type(key).__name__}, which JSON has no key for. Quote the key."
157
+ )
158
+ report(
159
+ Normalization(
160
+ source,
161
+ _child(path, text),
162
+ f"was keyed by {key!r}, stored under the string {text!r}: "
163
+ "JSON objects are keyed by strings. Quote the key to keep it as written.",
164
+ )
165
+ )
166
+ return text
167
+
168
+
169
+ def _json_value(value, *, source: str, path: str, report: Report):
170
+ """`value` as something JSON can carry, reporting every change on the way.
171
+
172
+ The audit behind the branches (issue #1254, checked against PyYAML rather
173
+ than recalled) is `tests/test_hand_edited_yaml.py`: of everything
174
+ `yaml.SafeLoader` can build, JSON has no timestamp, no bytes, no set, no
175
+ tuple and no non-string key, and cannot write a non-finite float. What has
176
+ a faithful text form is converted and reported; what does not is refused
177
+ here, naming the file and the key, instead of surfacing as a `TypeError`
178
+ from inside httpx.
179
+ """
180
+ if isinstance(value, dict):
181
+ normalized = {}
182
+ for key, item in value.items():
183
+ name = _json_key(key, source=source, path=path, report=report)
184
+ normalized[name] = _json_value(
185
+ item, source=source, path=_child(path, name), report=report
186
+ )
187
+ return normalized
188
+
189
+ if isinstance(value, (list, tuple, set, frozenset)):
190
+ return _json_sequence(value, source=source, path=path, report=report)
191
+
192
+ if isinstance(value, (datetime, date, time)):
193
+ # Only an explicit !!timestamp reaches this: see `_ConfigLoader`.
194
+ text = value.isoformat()
195
+ report(
196
+ Normalization(
197
+ source,
198
+ path,
199
+ f"is a YAML timestamp, stored as the string {text!r}: the config "
200
+ "has no timestamp type. Drop the !!timestamp tag to keep it as written.",
201
+ )
202
+ )
203
+ return text
204
+
205
+ if isinstance(value, bytes):
206
+ raise ConfigDirectoryError(
207
+ f"{source}: {path} is !!binary, which the config has no type for. "
208
+ "Decoding it back to text would be a guess: quote the value if you "
209
+ "meant the string, or drop the key."
210
+ )
211
+
212
+ if isinstance(value, float) and not isfinite(value):
213
+ raise ConfigDirectoryError(
214
+ f"{source}: {path} is {value}, which JSON has no number for "
215
+ "(`Infinity` and `NaN` are not JSON, and the backend stores the "
216
+ "config as jsonb). Use a finite number, or quote it as a string."
217
+ )
218
+
219
+ if value is None or isinstance(value, (str, bool, int, float)):
220
+ return value
221
+
222
+ raise ConfigDirectoryError(
223
+ f"{source}: {path} is a {type(value).__name__}, which JSON cannot carry."
224
+ )
225
+
226
+
227
+ def _json_sequence(value, *, source: str, path: str, report: Report) -> list:
228
+ """A list, a !!set or an !!omap/!!pairs tuple, as a JSON array.
229
+
230
+ `sorted(..., key=repr)` because a set has no order to preserve and members
231
+ of mixed types are not comparable to each other: the point is only that two
232
+ reads of the same file produce the same list.
233
+ """
234
+ members = value
235
+ if isinstance(value, (set, frozenset)):
236
+ report(
237
+ Normalization(
238
+ source, path, "is a YAML set, stored as a sorted list: JSON has no set."
239
+ )
240
+ )
241
+ members = sorted(value, key=repr)
242
+ elif isinstance(value, tuple):
243
+ report(
244
+ Normalization(
245
+ source, path, "is a YAML pair, stored as a list: JSON has no tuple."
246
+ )
247
+ )
248
+
249
+ return [
250
+ _json_value(item, source=source, path=f"{path}[{index}]", report=report)
251
+ for index, item in enumerate(members)
252
+ ]
253
+
254
+
67
255
  def _file_stem(name: str) -> str:
68
256
  return "".join(c if c in _SAFE_FILENAME_CHARS else "_" for c in name) or "unnamed"
69
257
 
@@ -86,9 +274,9 @@ def _write_yaml(path: Path, data: dict) -> None:
86
274
  )
87
275
 
88
276
 
89
- def _load_yaml(path: Path) -> dict:
277
+ def _load_yaml(path: Path, report: Report = _ignore) -> dict:
90
278
  try:
91
- loaded = yaml.safe_load(path.read_text())
279
+ loaded = yaml.load(path.read_text(), Loader=_ConfigLoader)
92
280
  except yaml.YAMLError as exc:
93
281
  # A hand-edited file with a typo is the common case here, and a raw
94
282
  # ScannerError traceback names neither the file nor the fix.
@@ -97,7 +285,7 @@ def _load_yaml(path: Path) -> dict:
97
285
  return {}
98
286
  if not isinstance(loaded, dict):
99
287
  raise ConfigDirectoryError(f"{path} must contain a YAML mapping, got {type(loaded).__name__}")
100
- return loaded
288
+ return _json_value(loaded, source=str(path), path="", report=report)
101
289
 
102
290
 
103
291
  def canonical_config(config: dict) -> dict:
@@ -158,13 +346,20 @@ def write_config_directory(output_dir: Path, config: dict) -> None:
158
346
  stale.unlink()
159
347
 
160
348
 
161
- def read_config_directory(connector_dir: Path) -> dict:
162
- """Read a local connector directory back into a config v3 blob."""
349
+ def read_config_directory(connector_dir: Path, *, report: Report | None = None) -> dict:
350
+ """Read a local connector directory back into a config v3 blob.
351
+
352
+ `report` is called once per value that could not reach the API as written
353
+ (see `Normalization`). Commands pass a printer: the CLI says what it
354
+ changed instead of changing it quietly. Omitting it discards the notes,
355
+ which is only ever right for a caller that has already been told.
356
+ """
357
+ report = report or _ignore
163
358
  config_path = connector_dir / CONFIG_FILE_NAME
164
359
  if not config_path.exists():
165
360
  raise ConfigDirectoryError(f"No {CONFIG_FILE_NAME} found in {connector_dir}")
166
361
 
167
- config = _load_yaml(config_path)
362
+ config = _load_yaml(config_path, report)
168
363
  for key in COLLECTION_DIRS:
169
364
  if key in config:
170
365
  raise ConfigDirectoryError(
@@ -176,7 +371,7 @@ def read_config_directory(connector_dir: Path) -> dict:
176
371
  for key, dir_name in COLLECTION_DIRS.items():
177
372
  collection_dir = connector_dir / dir_name
178
373
  items = [
179
- _load_yaml(path) for path in sorted(collection_dir.glob("*.yaml"))
374
+ _load_yaml(path, report) for path in sorted(collection_dir.glob("*.yaml"))
180
375
  ] if collection_dir.is_dir() else []
181
376
  for item in items:
182
377
  _item_name(item, key, f"{dir_name}/")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plugsync-cli
3
- Version: 0.3.0
3
+ Version: 0.4.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,24 +1,25 @@
1
- plugsync_cli/__init__.py,sha256=KNokQtEIi9Xm18J2kpSGo7zpzEiESULyERWUNoWy4AI,75
1
+ plugsync_cli/__init__.py,sha256=YqKMzI6PXaThQdfy4ni_oa1Sw1_ntpXuq3t1SJ9wBlM,75
2
2
  plugsync_cli/bundler.py,sha256=nCskRqAILHe3LLMZSyS4klBbM879yBvVJODpZvEEm38,2219
3
- plugsync_cli/client.py,sha256=zzRIpuZjtCidet54atxOdNLMGKegnnTRcdRnRbcexG8,11857
3
+ plugsync_cli/client.py,sha256=DfcmWtaX69WuWngacmiV6vk6_xZqRVlZq2c6uYyT1PE,13966
4
4
  plugsync_cli/compat.py,sha256=DCN-DO8FVQDjTz3sgMW3JAjIAWNhplan4zibDnwjK9w,4106
5
5
  plugsync_cli/config.py,sha256=z2DntLCY1RPU5gcqdDgkJ97fRd18clsXTvAlzLLcz10,1888
6
- plugsync_cli/context.py,sha256=_VLuzSq9ai0ufi8rWEgKD8ti4313lqY8X7mFhf8Ge7Y,7485
6
+ plugsync_cli/context.py,sha256=RsKFNuEpKINExjyU-fKng6IIR9L-x8tr4TiCRHgIazI,7810
7
7
  plugsync_cli/findings.py,sha256=V4A60XI2rtfLTFj7kpGJivVFiUfIhu50N3FdCGRnmbU,5005
8
8
  plugsync_cli/main.py,sha256=nUy6awLOZ4Uy5YW6bDUhjsvBgCet0SCM2_rrlgnL304,3805
9
- plugsync_cli/serializer.py,sha256=tNYxJpj7zdViStaKQct7F-HTk6N7SXYRRjIHoRn4_b8,9582
9
+ plugsync_cli/schema_defaults.py,sha256=tSMmm9cjQlj2YvN7Q20s0rwoHjiOdH6CLttlfbLzmaE,8089
10
+ plugsync_cli/serializer.py,sha256=93l_C88PsCj1fPMZMSTLXMY_D-bfFgpvjsDWiWwwJaQ,17353
10
11
  plugsync_cli/commands/__init__.py,sha256=wgYTWnZKXF-4M9ITNDhZMTUJH6_FthcUt_5zGj9NS8I,20
11
12
  plugsync_cli/commands/auth.py,sha256=cAcN8ALfkEOcejB4s4zq19dxS6v6iAw691B0HPTQd-Q,5590
12
- plugsync_cli/commands/diff.py,sha256=A4jc3nwNqJdZ1SZ1UNu2lOtukW8QJ9RY_q_pFCyo9hQ,4900
13
+ plugsync_cli/commands/diff.py,sha256=9XR6heMWXv3PhBEP9yH2UvgVqBb5UeDXchAvIQhRwpU,17821
13
14
  plugsync_cli/commands/log.py,sha256=49GBgAvvs07d-OwMEgKKc7WalCKH2xmr5l7Ov6N13jM,3376
14
- plugsync_cli/commands/plugin.py,sha256=ZNku_q4kQ_Aefz1EKysTeglJi74cAtX53FfB7VWUWuw,15715
15
+ plugsync_cli/commands/plugin.py,sha256=as1BM6lxpPcdrE12D--BCbSiriCLJbZED8utqJIGos8,20018
15
16
  plugsync_cli/commands/preview.py,sha256=yAgXnVsViyaHS8uk4akP7OgJEmCyFUmKSbeW2S0AhBs,2534
16
17
  plugsync_cli/commands/pull.py,sha256=UPD7riTKIo-bCiDqrW2v_Ipsb5AgOQxlOqsEeSvl7nU,2624
17
- plugsync_cli/commands/push.py,sha256=DAicwZYS-A3H8xiOVO0OSplM-9Em8fLG8yOsNJJxT3s,4019
18
+ plugsync_cli/commands/push.py,sha256=j8yGJ_1j6da_0Xz4UI6im2b3G3_SF8z8VfidPFrnCkg,5160
18
19
  plugsync_cli/commands/rollback.py,sha256=OtBk4U7yZubNVwhRmtyX0tSl20oWq8Pcy1Apjj_Ep-0,2471
19
20
  plugsync_cli/commands/validate.py,sha256=1ZjGSSkb21_2URmlyNaBBZXh-MI4L6cU_biKwJ6Itr4,1670
20
- plugsync_cli-0.3.0.dist-info/METADATA,sha256=vqI5imMzJ5N8WIEMBm4A1FPCw6ioNfN0hJGgXwhs2nQ,4152
21
- plugsync_cli-0.3.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
22
- plugsync_cli-0.3.0.dist-info/entry_points.txt,sha256=qWyWFQxY9W7L0hEwoDFjQjgs7TxJMsETk2nIe8jCA9E,51
23
- plugsync_cli-0.3.0.dist-info/top_level.txt,sha256=tShdp15OlzUttfE_QlFMLIfwQfImdp4YVhmB5rXZCgA,13
24
- plugsync_cli-0.3.0.dist-info/RECORD,,
21
+ plugsync_cli-0.4.0.dist-info/METADATA,sha256=QCx-iRUs178TdYKA3Ez67sN4KzEbdJ2xVA8wyTP9Lt4,4152
22
+ plugsync_cli-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
23
+ plugsync_cli-0.4.0.dist-info/entry_points.txt,sha256=qWyWFQxY9W7L0hEwoDFjQjgs7TxJMsETk2nIe8jCA9E,51
24
+ plugsync_cli-0.4.0.dist-info/top_level.txt,sha256=tShdp15OlzUttfE_QlFMLIfwQfImdp4YVhmB5rXZCgA,13
25
+ plugsync_cli-0.4.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (83.0.0)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5