plugsync-cli 0.5.0__tar.gz → 0.7.0__tar.gz

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