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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/PKG-INFO +1 -1
  2. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/__init__.py +1 -1
  3. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/diff.py +49 -2
  4. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/preview.py +12 -4
  5. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/pull.py +7 -7
  6. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/push.py +8 -5
  7. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/context.py +87 -5
  8. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/findings.py +13 -4
  9. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/serializer.py +182 -31
  10. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/PKG-INFO +1 -1
  11. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/pyproject.toml +1 -1
  12. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_commands.py +333 -0
  13. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_e2e_draft_api.py +91 -0
  14. plugsync_cli-0.6.0/tests/test_serializer.py +445 -0
  15. plugsync_cli-0.5.0/tests/test_serializer.py +0 -239
  16. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/README.md +0 -0
  17. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/bundler.py +0 -0
  18. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/client.py +0 -0
  19. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/__init__.py +0 -0
  20. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/auth.py +0 -0
  21. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/connector.py +0 -0
  22. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/log.py +0 -0
  23. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/plugin.py +0 -0
  24. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/rollback.py +0 -0
  25. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/validate.py +0 -0
  26. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/compat.py +0 -0
  27. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/config.py +0 -0
  28. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/main.py +0 -0
  29. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/schema_defaults.py +0 -0
  30. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/SOURCES.txt +0 -0
  31. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
  32. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
  33. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/requires.txt +0 -0
  34. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/top_level.txt +0 -0
  35. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/setup.cfg +0 -0
  36. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_api_contract.py +0 -0
  37. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_auth_commands.py +0 -0
  38. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_client.py +0 -0
  39. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_compat.py +0 -0
  40. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_config.py +0 -0
  41. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_connector_commands.py +0 -0
  42. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_hand_edited_yaml.py +0 -0
  43. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_plugin_commands.py +0 -0
  44. {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_schema_defaults.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plugsync-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required
5
5
  Author-email: Exelab <hello@plugsync.com>
6
6
  License: Proprietary
@@ -1,2 +1,2 @@
1
1
  """PluSync CLI — git-like connector management."""
2
- __version__ = "0.5.0"
2
+ __version__ = "0.6.0"
@@ -51,15 +51,20 @@ from typing import Any
51
51
 
52
52
  import click
53
53
  from rich.console import Console
54
+ from rich.markup import escape
54
55
  from rich.table import Table
55
56
 
56
57
  from plugsync_cli.context import build_client, fail, load_context
57
58
  from plugsync_cli.schema_defaults import SchemaNode, plugin_embedded_fields, schema_root
59
+ from plugsync_cli.serializer import OPTIONAL_COLLECTIONS
58
60
 
59
61
  console = Console()
60
62
 
61
63
  # Top-level config keys compared item-by-item (keyed by name) rather than whole.
62
- COLLECTIONS = {"entities": "entity", "flows": "flow"}
64
+ # `sources` reads like the other two here: a published version from before the
65
+ # sources were versioned simply carries no key, so every local source shows up
66
+ # as added, which is what it is against that version.
67
+ COLLECTIONS = {"entities": "entity", "flows": "flow", "sources": "source"}
63
68
 
64
69
  # Keys listed in one Detail cell before the rest is summarized. A row exists to
65
70
  # tell an operator where to look, and forty paths in a table cell do not.
@@ -198,6 +203,7 @@ def _diff_configs(local: dict, remote: dict, root: SchemaNode) -> list[dict]:
198
203
 
199
204
  for key, label in COLLECTIONS.items():
200
205
  member = root.child(key).item()
206
+ changes.extend(_declaration_change(key, local, remote))
201
207
  local_items, remote_items = _by_name(local.get(key)), _by_name(remote.get(key))
202
208
  for name in sorted(set(local_items) | set(remote_items)):
203
209
  if name not in remote_items:
@@ -228,6 +234,42 @@ def _diff_configs(local: dict, remote: dict, root: SchemaNode) -> list[dict]:
228
234
  return changes
229
235
 
230
236
 
237
+ def _declaration_change(key: str, local: dict, remote: dict) -> list[dict]:
238
+ """The row for a collection one side declares and the other does not.
239
+
240
+ Only an optional collection can be in that position, and for `sources` the
241
+ difference between "declared, and empty" and "not declared at all" is the
242
+ whole point of the key: the first puts the definitions under the
243
+ connector's config, the second leaves them to whatever manages them outside
244
+ it. Item by item the two sides look identical -- no sources either way --
245
+ so without this row the operator sees nothing at all, and the direction
246
+ that DROPS the key is the one that reads as "no changes" while changing
247
+ which document owns the definitions.
248
+ """
249
+ if key not in OPTIONAL_COLLECTIONS:
250
+ return []
251
+ in_local, in_remote = local.get(key) is not None, remote.get(key) is not None
252
+ if in_local == in_remote:
253
+ return []
254
+ if in_local:
255
+ return [
256
+ _simple(
257
+ f"{key}_key_added",
258
+ key,
259
+ f"declared in your files, absent from the published version: "
260
+ f"publishing them puts the {key} under the connector's config",
261
+ )
262
+ ]
263
+ return [
264
+ _simple(
265
+ f"{key}_key_removed",
266
+ key,
267
+ f"declared in the published version, absent from your files: "
268
+ f"pushing this directory takes the {key} out of the config",
269
+ )
270
+ ]
271
+
272
+
231
273
  def _simple(change_type: str, scope: str, detail: str) -> dict:
232
274
  return {
233
275
  "type": change_type,
@@ -264,7 +306,12 @@ def _table(changes: list[dict]) -> Table:
264
306
  # name -- which is the part this column exists to show.
265
307
  table.add_column("Detail", overflow="fold")
266
308
  for change in changes:
267
- table.add_row(_mark(change["type"]), change["scope"], change["detail"])
309
+ # escape: item names and key paths come from the config, and one in
310
+ # brackets is valid rich markup that would be read as a style tag and
311
+ # swallowed, taking the name this column exists to show with it.
312
+ table.add_row(
313
+ _mark(change["type"]), escape(change["scope"]), escape(change["detail"])
314
+ )
268
315
  return table
269
316
 
270
317
 
@@ -1,6 +1,7 @@
1
1
  """plugsync preview -- what publishing the draft would change."""
2
2
  import click
3
3
  from rich.console import Console
4
+ from rich.markup import escape
4
5
  from rich.table import Table
5
6
 
6
7
  from plugsync_cli.context import (
@@ -55,11 +56,18 @@ def preview(path: str | None):
55
56
  console.print(f"\n[bold]Publishing would apply {len(changes)} change(s)[/bold]\n")
56
57
  table = Table(show_header=True, header_style="bold")
57
58
  table.add_column("Change", width=24)
58
- table.add_column("Detail")
59
+ # fold, and escape below: a change detail carries the key path the
60
+ # server names it by, and a name in brackets (`sources[hubspot].config`)
61
+ # is both valid rich markup, which would swallow the name, and one
62
+ # unbreakable word, which ellipsizing would cut at the wrong end.
63
+ table.add_column("Detail", overflow="fold")
59
64
  for change in changes:
60
- scope = change.get("entity") or change.get("event_flow") or ""
61
- detail = change.get("detail", "")
62
- table.add_row(_mark(change.get("type", "unknown")), f"[bold]{scope}[/bold] {detail}" if scope else detail)
65
+ scope = escape(str(change.get("entity") or change.get("event_flow") or ""))
66
+ detail = escape(str(change.get("detail", "")))
67
+ table.add_row(
68
+ _mark(change.get("type", "unknown")),
69
+ f"[bold]{scope}[/bold] {detail}" if scope else detail,
70
+ )
63
71
  console.print(table)
64
72
 
65
73
  render_preflight(console, client.preflight(context.connector_id))
@@ -4,7 +4,12 @@ from pathlib import Path
4
4
  import click
5
5
  from rich.console import Console
6
6
 
7
- from plugsync_cli.context import build_client, fail, warn_if_flow_order_is_not_preserved
7
+ from plugsync_cli.context import (
8
+ build_client,
9
+ describe_config,
10
+ fail,
11
+ warn_if_flow_order_is_not_preserved,
12
+ )
8
13
  from plugsync_cli.serializer import write_config_directory, write_local_state
9
14
 
10
15
  console = Console()
@@ -61,10 +66,5 @@ def pull(connector_name: str, revision: int | None, output: str | None):
61
66
 
62
67
  warn_if_flow_order_is_not_preserved(config)
63
68
 
64
- entities = len(config.get("entities") or [])
65
- flows = len(config.get("flows") or [])
66
69
  console.print(f"[green]Pulled {state['source']} to {output_dir}/[/green]")
67
- console.print(
68
- f" {entities} entit{'y' if entities == 1 else 'ies'}, "
69
- f"{flows} flow{'' if flows == 1 else 's'}"
70
- )
70
+ console.print(f" {describe_config(config)}")
@@ -9,7 +9,13 @@ config to production as a side effect of saving a file (#1241).
9
9
  import click
10
10
  from rich.console import Console
11
11
 
12
- from plugsync_cli.context import build_client, fail, load_context, write_draft_from_directory
12
+ from plugsync_cli.context import (
13
+ build_client,
14
+ describe_config,
15
+ fail,
16
+ load_context,
17
+ write_draft_from_directory,
18
+ )
13
19
  from plugsync_cli.findings import render_findings, render_preflight
14
20
 
15
21
  console = Console()
@@ -87,12 +93,9 @@ def push(
87
93
  console.print(f"Pushing [bold]{context.directory.name}[/bold] to the draft...")
88
94
  draft = write_draft_from_directory(client, context, force=force)
89
95
 
90
- config = draft["config"]
91
- entities = len(config.get("entities") or [])
92
- flows = len(config.get("flows") or [])
93
96
  console.print(
94
97
  f"[green]Draft updated (draft v{draft['draft_version']}): "
95
- f"{entities} entities, {flows} flows[/green]"
98
+ f"{describe_config(draft['config'])}[/green]"
96
99
  )
97
100
 
98
101
  if not do_publish:
@@ -9,15 +9,17 @@ from dataclasses import dataclass
9
9
  from pathlib import Path
10
10
  from typing import NoReturn
11
11
 
12
+ import httpx
12
13
  from rich.console import Console
13
14
 
14
- from plugsync_cli.client import DraftConflict, PlugSyncClient
15
+ from plugsync_cli.client import DraftConflict, PlugSyncClient, describe_http_error
15
16
  from plugsync_cli.serializer import (
16
17
  CONFIG_FILE_NAME,
17
18
  ConfigDirectoryError,
18
19
  Normalization,
19
20
  canonical_config,
20
21
  flows_sharing_a_trigger,
22
+ locate_in_directory,
21
23
  read_config_directory,
22
24
  read_local_state,
23
25
  write_config_directory,
@@ -128,10 +130,21 @@ def write_draft_from_directory(
128
130
  cosmetic: it is what `validate` and `preview` warn about, and a warning that
129
131
  fires every time stops being read exactly when it matters.
130
132
  """
131
- expected_version = None if force else _expected_draft_version(client, context)
133
+ config = context.config()
134
+ # Read once, shared: the warning below and the concurrency guard both want
135
+ # the remote draft, and neither is worth a round trip of its own.
136
+ remote_draft = (
137
+ client.get_draft(context.connector_id) if config.get("sources") is None else None
138
+ )
139
+ if remote_draft is not None:
140
+ warn_if_the_push_takes_the_sources_out_of_the_config(remote_draft["config"])
141
+
142
+ expected_version = (
143
+ None if force else _expected_draft_version(client, context, remote_draft)
144
+ )
132
145
  try:
133
146
  draft = client.put_draft(
134
- context.connector_id, context.config(), expected_version=expected_version
147
+ context.connector_id, config, expected_version=expected_version
135
148
  )
136
149
  except DraftConflict as conflict:
137
150
  fail(
@@ -139,6 +152,8 @@ def write_draft_from_directory(
139
152
  "it. Re-run 'plugsync pull' and reapply your changes, or "
140
153
  "'plugsync push --force' to overwrite theirs."
141
154
  )
155
+ except httpx.HTTPStatusError as exc:
156
+ fail(_rejection_pointing_at_the_files(exc))
142
157
 
143
158
  warn_if_flow_order_is_not_preserved(draft["config"])
144
159
  write_config_directory(context.directory, draft["config"])
@@ -155,17 +170,84 @@ def write_draft_from_directory(
155
170
  return draft
156
171
 
157
172
 
158
- def _expected_draft_version(client: PlugSyncClient, context: ConnectorContext) -> int:
173
+ def _rejection_pointing_at_the_files(exc: httpx.HTTPStatusError) -> str:
174
+ """The API's refusal, with every config path it names resolved to a file.
175
+
176
+ A rejected draft is described the way the config document reads
177
+ (`sources[hubspot].config.triggers[0].pattern`), which is not how the
178
+ operator is looking at it: they have a directory of files open. Naming the
179
+ file that holds the key, alongside the backend's own explanation, is what
180
+ makes the failure actionable without decoding the path by hand.
181
+ """
182
+ message = describe_http_error(exc)
183
+ located = locate_in_directory(message)
184
+ if not located:
185
+ return message
186
+ lines = "\n".join(
187
+ f" {file_name}: {key}" if key else f" {file_name}" for file_name, key in located
188
+ )
189
+ return f"{message}\n\nIn your files:\n{lines}"
190
+
191
+
192
+ def describe_config(config: dict) -> str:
193
+ """What a config holds, counted, for the line a pull or a push ends on.
194
+
195
+ `sources` is reported only when the config declares the key. Its absence
196
+ means the connector's sources are not versioned in the config at all, which
197
+ is not the same as having none: printing "0 sources" would say the opposite
198
+ of what an absent key means.
199
+ """
200
+ counted = [
201
+ _counted(config.get("entities"), "entity", "entities"),
202
+ _counted(config.get("flows"), "flow", "flows"),
203
+ ]
204
+ if config.get("sources") is not None:
205
+ counted.append(_counted(config["sources"], "source", "sources"))
206
+ return ", ".join(counted)
207
+
208
+
209
+ def _counted(items, singular: str, plural: str) -> str:
210
+ count = len(items or [])
211
+ return f"{count} {singular if count == 1 else plural}"
212
+
213
+
214
+ def warn_if_the_push_takes_the_sources_out_of_the_config(remote_config: dict) -> None:
215
+ """Say it when this push drops a `sources` key the connector has.
216
+
217
+ A directory with no `sources/` sends a config with no `sources` key, and
218
+ that is not "this connector has no sources": it is the switch that hands
219
+ the source definitions back to being managed outside the config. A
220
+ directory pulled before the sources were part of it flips that switch on
221
+ the next push, silently and with a zero exit code, which is why the one
222
+ push that does it says so.
223
+ """
224
+ if remote_config.get("sources") is None:
225
+ return
226
+ console.print(
227
+ "[yellow]The connector's draft declares 'sources' and this directory "
228
+ "does not, so this push takes the source definitions out of the "
229
+ "config: they go back to being managed outside it, and a later publish "
230
+ "leaves them alone. Run 'plugsync pull' first if you meant to keep them "
231
+ "in the directory.[/yellow]"
232
+ )
233
+
234
+
235
+ def _expected_draft_version(
236
+ client: PlugSyncClient, context: ConnectorContext, draft: dict | None = None
237
+ ) -> int:
159
238
  """The draft version a write guards itself with.
160
239
 
161
240
  Normally the one recorded at pull time, which is what makes a concurrent
162
241
  edit detectable. A directory pulled with `--revision` has none: there the
163
242
  current draft version is read now, which still rules out a write racing this
164
243
  command, and the weaker guarantee is stated out loud rather than assumed.
244
+
245
+ `draft` is the remote draft when the caller has already read it, so the two
246
+ reasons this function's caller has to fetch it cost one request, not two.
165
247
  """
166
248
  if context.draft_version is not None:
167
249
  return context.draft_version
168
- current = client.get_draft(context.connector_id)["draft_version"]
250
+ current = (draft or client.get_draft(context.connector_id))["draft_version"]
169
251
  console.print(
170
252
  f"[yellow]This directory was pulled from a published version, not the "
171
253
  f"draft: guarding against edits after draft v{current} only.[/yellow]"
@@ -8,6 +8,7 @@ severity therefore gets a glyph, a colour AND the literal word -- the word being
8
8
  the part that still reads correctly in a CI log with colour stripped.
9
9
  """
10
10
  from rich.console import Console
11
+ from rich.markup import escape
11
12
 
12
13
  # severity -> (glyph, rich colour). Ordered worst-first: that is the order
13
14
  # findings are printed in, so the thing that blocks you is not below the noise.
@@ -34,13 +35,21 @@ def _summary(counts: dict[str, int]) -> str:
34
35
 
35
36
 
36
37
  def _render_one(console: Console, finding: dict) -> None:
38
+ """One finding, with everything the backend wrote printed verbatim.
39
+
40
+ `escape` on every backend-authored string: a finding names the key it is
41
+ about the way the config document does, and a name in brackets --
42
+ `sources[hubspot].config.triggers[0].pattern` -- is valid rich markup. Rich
43
+ read `[hubspot]` as a style tag and swallowed it, so the one word saying
44
+ WHICH source was wrong disappeared from the screen.
45
+ """
37
46
  severity = finding.get("severity", "info")
38
47
  # A severity this CLI does not know still gets printed, under its own name.
39
48
  glyph, colour = SEVERITY_STYLES.get(severity, ("?", "magenta"))
40
- code = finding.get("code", "unknown")
49
+ code = escape(str(finding.get("code", "unknown")))
50
+ message = escape(str(finding.get("message", "")))
41
51
  console.print(
42
- f" [{colour}]{glyph} {severity:<7}[/{colour}] "
43
- f"[bold]{code}[/bold]: {finding.get('message', '')}"
52
+ f" [{colour}]{glyph} {severity:<7}[/{colour}] [bold]{code}[/bold]: {message}"
44
53
  )
45
54
  scope = finding.get("entity") or finding.get("event_flow")
46
55
  for label, value in (
@@ -49,7 +58,7 @@ def _render_one(console: Console, finding: dict) -> None:
49
58
  ("fix", finding.get("suggestion")),
50
59
  ):
51
60
  if value:
52
- console.print(f" [dim]{label}:[/dim] {value}")
61
+ console.print(f" [dim]{label}:[/dim] {escape(str(value))}")
53
62
 
54
63
 
55
64
  def _severity_rank(severity: str) -> int:
@@ -1,15 +1,17 @@
1
1
  """Read and write a connector config v3 blob as a local directory.
2
2
 
3
3
  The directory IS the config: `plugsync.yaml` holds every top-level key except
4
- the two collections, which become one file per named item.
4
+ the named collections, which become one file per item.
5
5
 
6
6
  connector-name/
7
7
  |-- plugsync.yaml # version, settings, conflicts, pipelines, ...
8
8
  |-- entities/
9
9
  | |-- contacts.yaml
10
10
  | `-- orders.yaml
11
- `-- flows/
12
- `-- shop-order-created.yaml
11
+ |-- flows/
12
+ | `-- shop-order-created.yaml
13
+ `-- sources/
14
+ `-- hubspot.yaml
13
15
 
14
16
  Serialization lives here, on the client, rather than behind a server-side
15
17
  export/import endpoint (issue #1241): draft and version are already JSON on
@@ -44,6 +46,7 @@ The config is a JSON document, so reading is also where a file stops being
44
46
  "whatever YAML built" and becomes "something JSON can carry" -- see
45
47
  `_ConfigLoader` and `_json_value` (issue #1254).
46
48
  """
49
+ import re
47
50
  from collections.abc import Callable
48
51
  from dataclasses import dataclass
49
52
  from datetime import date, datetime, time
@@ -57,7 +60,26 @@ LOCAL_STATE_FILE_NAME = ".plugsync.yaml"
57
60
 
58
61
  # Top-level config keys stored as a directory of named files instead of inline
59
62
  # in plugsync.yaml, mapped to the directory that holds them.
60
- COLLECTION_DIRS = {"entities": "entities", "flows": "flows"}
63
+ COLLECTION_DIRS = {"entities": "entities", "flows": "flows", "sources": "sources"}
64
+
65
+ # Collections whose ABSENCE means something other than "empty".
66
+ #
67
+ # `sources` is optional in the config (ADR-0026): a config with no `sources`
68
+ # key leaves the connector on the path where the event source rows are
69
+ # authoritative, while `sources: []` declares "this connector has no sources"
70
+ # and makes the config the authority for them. The switch is per connector and
71
+ # lives in the presence of the key, so the directory has to carry it: the
72
+ # `sources/` directory exists exactly when the key does, empty directory
73
+ # included. Materializing `sources: []` for a config that simply has no key --
74
+ # the way `entities` and `flows` are materialized, where absent and empty are
75
+ # the same thing -- would flip that switch on every pull.
76
+ OPTIONAL_COLLECTIONS = frozenset({"sources"})
77
+
78
+ # Written into an optional collection's directory when it holds no item, because
79
+ # git carries no empty directory and the directory is what declares the key:
80
+ # without it, pull -> commit -> clone -> push would drop `sources: []` and put
81
+ # the connector back on the legacy path with a zero exit code.
82
+ DIRECTORY_KEEP_FILE = ".gitkeep"
61
83
 
62
84
  # Characters kept as-is in a file name. Entity and flow names are free-form
63
85
  # strings server-side ("order.created", "shop/orders"), so anything else is
@@ -291,12 +313,16 @@ def _load_yaml(path: Path, report: Report = _ignore) -> dict:
291
313
  def canonical_config(config: dict) -> dict:
292
314
  """The config as the directory format represents it.
293
315
 
294
- Both collections present and sorted by name, so a config read from disk and
295
- one read from the API can be compared for equality (what `plugsync diff`,
296
- `validate` and `preview` do to tell local files from the remote draft).
316
+ Every collection sorted by name, and the mandatory ones present even when
317
+ empty, so a config read from disk and one read from the API can be compared
318
+ for equality (what `plugsync diff`, `validate` and `preview` do to tell
319
+ local files from the remote draft). An optional collection the config does
320
+ not declare stays undeclared: see `OPTIONAL_COLLECTIONS`.
297
321
  """
298
322
  canonical = {k: v for k, v in config.items() if k not in COLLECTION_DIRS}
299
323
  for key in COLLECTION_DIRS:
324
+ if key in OPTIONAL_COLLECTIONS and config.get(key) is None:
325
+ continue
300
326
  items = config.get(key) or []
301
327
  canonical[key] = sorted(items, key=lambda item: _item_name(item, key, "config"))
302
328
  return canonical
@@ -323,27 +349,72 @@ def write_config_directory(output_dir: Path, config: dict) -> None:
323
349
  )
324
350
 
325
351
  for key, dir_name in COLLECTION_DIRS.items():
326
- items = config.get(key) or []
327
352
  collection_dir = output_dir / dir_name
328
- if not items and not collection_dir.is_dir():
353
+ declared = config.get(key)
354
+ optional = key in OPTIONAL_COLLECTIONS
355
+
356
+ if optional and declared is None:
357
+ _undeclare_collection(collection_dir)
329
358
  continue
330
- collection_dir.mkdir(exist_ok=True)
359
+ items = declared or []
360
+ if not items and not optional and not collection_dir.is_dir():
361
+ continue
362
+ _write_collection(collection_dir, key, items, declares_itself=optional)
331
363
 
332
- written: dict[str, str] = {}
333
- for item in items:
334
- name = _item_name(item, key, "config")
335
- stem = _file_stem(name)
336
- if stem in written:
337
- raise ConfigDirectoryError(
338
- f"{key} {name!r} and {written[stem]!r} both map to the file "
339
- f"{stem}.yaml; rename one of them."
340
- )
341
- written[stem] = name
342
- _write_yaml(collection_dir / f"{stem}.yaml", item)
343
364
 
344
- for stale in collection_dir.glob("*.yaml"):
345
- if stale.stem not in written:
346
- stale.unlink()
365
+ def _write_collection(
366
+ collection_dir: Path, key: str, items: list, *, declares_itself: bool
367
+ ) -> None:
368
+ """One file per named item, and nothing else this format owns.
369
+
370
+ `declares_itself` says that the existence of this directory is what tells
371
+ the reader the config declares the key at all, which is true of the
372
+ optional collections and only of those. It is what earns the empty
373
+ directory a `DIRECTORY_KEEP_FILE`, and what takes it away again as soon as
374
+ a real item is there to hold the directory up.
375
+ """
376
+ collection_dir.mkdir(exist_ok=True)
377
+
378
+ written: dict[str, str] = {}
379
+ for item in items:
380
+ name = _item_name(item, key, "config")
381
+ stem = _file_stem(name)
382
+ if stem in written:
383
+ raise ConfigDirectoryError(
384
+ f"{key} {name!r} and {written[stem]!r} both map to the file "
385
+ f"{stem}.yaml; rename one of them."
386
+ )
387
+ written[stem] = name
388
+ _write_yaml(collection_dir / f"{stem}.yaml", item)
389
+
390
+ for stale in collection_dir.glob("*.yaml"):
391
+ if stale.stem not in written:
392
+ stale.unlink()
393
+
394
+ keep_file = collection_dir / DIRECTORY_KEEP_FILE
395
+ if declares_itself and not items:
396
+ keep_file.touch()
397
+ elif keep_file.exists():
398
+ keep_file.unlink()
399
+
400
+
401
+ def _undeclare_collection(collection_dir: Path) -> None:
402
+ """Erase an optional collection the config no longer declares.
403
+
404
+ Its directory is what declares the key, so leaving an emptied one behind
405
+ would make the next read say `sources: []` for a config that says nothing
406
+ about sources at all. A directory holding files this format does not own
407
+ (a README, a note) is emptied of its own and left standing.
408
+ """
409
+ if not collection_dir.is_dir():
410
+ return
411
+ for path in collection_dir.glob("*.yaml"):
412
+ path.unlink()
413
+ keep_file = collection_dir / DIRECTORY_KEEP_FILE
414
+ if keep_file.exists():
415
+ keep_file.unlink()
416
+ if not any(collection_dir.iterdir()):
417
+ collection_dir.rmdir()
347
418
 
348
419
 
349
420
  def read_config_directory(connector_dir: Path, *, report: Report | None = None) -> dict:
@@ -360,16 +431,14 @@ def read_config_directory(connector_dir: Path, *, report: Report | None = None)
360
431
  raise ConfigDirectoryError(f"No {CONFIG_FILE_NAME} found in {connector_dir}")
361
432
 
362
433
  config = _load_yaml(config_path, report)
363
- for key in COLLECTION_DIRS:
364
- if key in config:
365
- raise ConfigDirectoryError(
366
- f"{CONFIG_FILE_NAME} declares {key!r} inline, but {key} live in "
367
- f"the {COLLECTION_DIRS[key]}/ directory. Move them there, or "
368
- "re-run 'plugsync pull'."
369
- )
370
434
 
371
435
  for key, dir_name in COLLECTION_DIRS.items():
372
436
  collection_dir = connector_dir / dir_name
437
+ if key in config:
438
+ _read_inline_collection(config, key, dir_name, collection_dir)
439
+ continue
440
+ if key in OPTIONAL_COLLECTIONS and not collection_dir.is_dir():
441
+ continue
373
442
  items = [
374
443
  _load_yaml(path, report) for path in sorted(collection_dir.glob("*.yaml"))
375
444
  ] if collection_dir.is_dir() else []
@@ -380,6 +449,88 @@ def read_config_directory(connector_dir: Path, *, report: Report | None = None)
380
449
  return config
381
450
 
382
451
 
452
+ def _read_inline_collection(
453
+ config: dict, key: str, dir_name: str, collection_dir: Path
454
+ ) -> None:
455
+ """A collection plugsync.yaml still carries as a top-level key.
456
+
457
+ For `entities` and `flows` that is always an editing mistake, and ignoring
458
+ it would push a draft with every one of them deleted.
459
+
460
+ For `sources` it is what a CLI older than the `sources/` directory (0.5.0
461
+ and earlier) leaves behind: those releases preserve the key verbatim, so
462
+ the list is read as it stands and the next write splits it into files. What
463
+ is refused is a directory carrying BOTH -- two lists of sources, of which a
464
+ push would send one and delete the other.
465
+ """
466
+ if key not in OPTIONAL_COLLECTIONS:
467
+ raise ConfigDirectoryError(
468
+ f"{CONFIG_FILE_NAME} declares {key!r} inline, but {key} live in "
469
+ f"the {dir_name}/ directory. Move them there, or "
470
+ "re-run 'plugsync pull'."
471
+ )
472
+ if collection_dir.is_dir():
473
+ raise ConfigDirectoryError(
474
+ f"{CONFIG_FILE_NAME} declares {key!r} inline and {dir_name}/ also "
475
+ f"exists: two lists of {key}, and a push replaces the whole draft "
476
+ f"with one of them. Delete the {key!r} key from {CONFIG_FILE_NAME} "
477
+ f"to keep {dir_name}/, or delete {dir_name}/ to keep the key."
478
+ )
479
+ if config[key] is None:
480
+ # `sources:` with nothing after it. Not a declaration: read as an empty
481
+ # list it would turn a config that says nothing about sources into one
482
+ # declaring the connector has none, and it would disagree with
483
+ # `canonical_config`, which treats null as absent on the same document.
484
+ del config[key]
485
+ return
486
+ items = config[key]
487
+ for item in items:
488
+ _item_name(item, key, CONFIG_FILE_NAME)
489
+ config[key] = sorted(items, key=lambda item: item["name"])
490
+
491
+
492
+ # How the backend names a key inside a named collection item when it refuses a
493
+ # draft write: `sources[hubspot].config.triggers[0].pattern` (ADR-0026), the
494
+ # same shape the entity and flow diffs use.
495
+ #
496
+ # The name arrives quoted from part of the API (`sources['hubspot'] is declared
497
+ # twice`) and bare from the rest, so the quotes are stripped below rather than
498
+ # matched here: they are characters no file name may hold, and leaving them in
499
+ # mangled the path into `_hubspot_.yaml`, a file that does not exist.
500
+ _ITEM_PATH = re.compile(
501
+ r"\b(" + "|".join(COLLECTION_DIRS) + r")\[([^\[\]]+)\](?:\.([\w.\[\]]+))?"
502
+ )
503
+
504
+ # What the quoting above wraps the name in. Stripped, never mangled: a name may
505
+ # legitimately contain neither, so nothing real is lost.
506
+ _NAME_QUOTES = "'\""
507
+
508
+
509
+ def locate_in_directory(message: str) -> list[tuple[str, str]]:
510
+ """Every config path a backend message names, as `(file, key)` pairs.
511
+
512
+ The API rejects a config by the path the key has in the JSON document; the
513
+ operator has a DIRECTORY in front of them. Resolving
514
+ `sources[hubspot].config.triggers[0].pattern` to
515
+ `sources/hubspot.yaml` plus `config.triggers[0].pattern` is the difference
516
+ between a 422 they can act on and one they have to decode by hand -- and
517
+ the file name is mangled here exactly as `write_config_directory` mangles
518
+ it, so a source called `shop/eu` is pointed at the file that really holds
519
+ it.
520
+
521
+ Ordered as they appear in the message, each path reported once.
522
+ """
523
+ return list(
524
+ dict.fromkeys(
525
+ (
526
+ f"{COLLECTION_DIRS[collection]}/{_file_stem(name.strip(_NAME_QUOTES))}.yaml",
527
+ key.rstrip("."),
528
+ )
529
+ for collection, name, key in _ITEM_PATH.findall(message)
530
+ )
531
+ )
532
+
533
+
383
534
  def flows_sharing_a_trigger(config: dict) -> list[tuple[str, str, list[str]]]:
384
535
  """Flow groups whose relative execution order this format does not preserve.
385
536
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plugsync-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required
5
5
  Author-email: Exelab <hello@plugsync.com>
6
6
  License: Proprietary
@@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta"
11
11
 
12
12
  [project]
13
13
  name = "plugsync-cli"
14
- version = "0.5.0"
14
+ version = "0.6.0"
15
15
  description = "Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required"
16
16
  readme = "README.md"
17
17
  license = { text = "Proprietary" }