plugsync-cli 0.5.0__py3-none-any.whl → 0.7.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.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
+ )
plugsync_cli/context.py CHANGED
@@ -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
 
plugsync_cli/findings.py CHANGED
@@ -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,28 @@ 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 is not the same as "empty".
66
+ #
67
+ # `sources` is a mandatory key of the config (ADR-0026): `sources: []` declares
68
+ # "this connector has no sources", and the API refuses a draft write that
69
+ # omits the key, because read as "no sources" the omission would delete every
70
+ # source the connector has at the next publish. The directory declares the key
71
+ # through the `sources/` directory, empty directory included.
72
+ #
73
+ # So a directory with neither `sources/` nor an inline `sources` key is
74
+ # refused on read (see `undeclared_collection_message`). Materializing
75
+ # `sources: []` for it -- the way `entities` and `flows` are materialized,
76
+ # where absent and empty are the same thing -- would turn "the directory is
77
+ # missing" into "delete all my sources". The CLI stops instead of guessing.
78
+ OPTIONAL_COLLECTIONS = frozenset({"sources"})
79
+
80
+ # Written into an optional collection's directory when it holds no item, because
81
+ # git carries no empty directory and the directory is what declares the key:
82
+ # without it, pull -> commit -> clone -> push would find no `sources/` and
83
+ # refuse a directory that declared `sources: []` when it was pulled.
84
+ DIRECTORY_KEEP_FILE = ".gitkeep"
61
85
 
62
86
  # Characters kept as-is in a file name. Entity and flow names are free-form
63
87
  # strings server-side ("order.created", "shop/orders"), so anything else is
@@ -72,6 +96,25 @@ class ConfigDirectoryError(Exception):
72
96
  """The local directory is not a readable connector config."""
73
97
 
74
98
 
99
+ def undeclared_collection_message(connector_dir: Path, key: str) -> str:
100
+ """What to do about a directory that does not declare `key` at all.
101
+
102
+ Shared by the local refusal in `read_config_directory` and by the
103
+ translation of the API's own refusal of the same thing, so the operator
104
+ reads one explanation whichever of the two catches it.
105
+ """
106
+ dir_name = COLLECTION_DIRS[key]
107
+ return (
108
+ f"{connector_dir} does not declare '{key}': there is no {dir_name}/ "
109
+ f"directory and no '{key}' key in {CONFIG_FILE_NAME}. Pushing it would "
110
+ f"ask the server to drop the connector's {key}, which it refuses. "
111
+ f"Run 'plugsync pull' to realign the directory with the draft; or "
112
+ f"create {dir_name}/ with an empty {DIRECTORY_KEEP_FILE} (and one file "
113
+ f"per {key[:-1]}) if you keep them as files; or write '{key}: []' in "
114
+ f"{CONFIG_FILE_NAME} if the connector really has no {key}."
115
+ )
116
+
117
+
75
118
  _TIMESTAMP_TAG = "tag:yaml.org,2002:timestamp"
76
119
 
77
120
 
@@ -291,12 +334,18 @@ def _load_yaml(path: Path, report: Report = _ignore) -> dict:
291
334
  def canonical_config(config: dict) -> dict:
292
335
  """The config as the directory format represents it.
293
336
 
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).
337
+ Every collection sorted by name, and the mandatory ones present even when
338
+ empty, so a config read from disk and one read from the API can be compared
339
+ for equality (what `plugsync diff`, `validate` and `preview` do to tell
340
+ local files from the remote draft). An optional collection the config does
341
+ not declare stays undeclared: a version published before the sources were
342
+ part of the config carries no `sources` key, and inventing `sources: []`
343
+ for it would describe that version as something it never said.
297
344
  """
298
345
  canonical = {k: v for k, v in config.items() if k not in COLLECTION_DIRS}
299
346
  for key in COLLECTION_DIRS:
347
+ if key in OPTIONAL_COLLECTIONS and config.get(key) is None:
348
+ continue
300
349
  items = config.get(key) or []
301
350
  canonical[key] = sorted(items, key=lambda item: _item_name(item, key, "config"))
302
351
  return canonical
@@ -323,27 +372,75 @@ def write_config_directory(output_dir: Path, config: dict) -> None:
323
372
  )
324
373
 
325
374
  for key, dir_name in COLLECTION_DIRS.items():
326
- items = config.get(key) or []
327
375
  collection_dir = output_dir / dir_name
328
- if not items and not collection_dir.is_dir():
376
+ declared = config.get(key)
377
+ optional = key in OPTIONAL_COLLECTIONS
378
+
379
+ if optional and declared is None:
380
+ _undeclare_collection(collection_dir)
381
+ continue
382
+ items = declared or []
383
+ if not items and not optional and not collection_dir.is_dir():
329
384
  continue
330
- collection_dir.mkdir(exist_ok=True)
385
+ _write_collection(collection_dir, key, items, declares_itself=optional)
331
386
 
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
387
 
344
- for stale in collection_dir.glob("*.yaml"):
345
- if stale.stem not in written:
346
- stale.unlink()
388
+ def _write_collection(
389
+ collection_dir: Path, key: str, items: list, *, declares_itself: bool
390
+ ) -> None:
391
+ """One file per named item, and nothing else this format owns.
392
+
393
+ `declares_itself` says that the existence of this directory is what tells
394
+ the reader the config declares the key at all, which is true of the
395
+ optional collections and only of those. It is what earns the empty
396
+ directory a `DIRECTORY_KEEP_FILE`, and what takes it away again as soon as
397
+ a real item is there to hold the directory up.
398
+ """
399
+ collection_dir.mkdir(exist_ok=True)
400
+
401
+ written: dict[str, str] = {}
402
+ for item in items:
403
+ name = _item_name(item, key, "config")
404
+ stem = _file_stem(name)
405
+ if stem in written:
406
+ raise ConfigDirectoryError(
407
+ f"{key} {name!r} and {written[stem]!r} both map to the file "
408
+ f"{stem}.yaml; rename one of them."
409
+ )
410
+ written[stem] = name
411
+ _write_yaml(collection_dir / f"{stem}.yaml", item)
412
+
413
+ for stale in collection_dir.glob("*.yaml"):
414
+ if stale.stem not in written:
415
+ stale.unlink()
416
+
417
+ keep_file = collection_dir / DIRECTORY_KEEP_FILE
418
+ if declares_itself and not items:
419
+ keep_file.touch()
420
+ elif keep_file.exists():
421
+ keep_file.unlink()
422
+
423
+
424
+ def _undeclare_collection(collection_dir: Path) -> None:
425
+ """Erase an optional collection the config does not declare.
426
+
427
+ Only a version published before the sources were part of the config
428
+ (`pull --revision`) comes without the key. Its directory is what declares
429
+ the key, so leaving an emptied one behind would make the next read say
430
+ `sources: []` for a version that says nothing about sources at all; without
431
+ it, the next read refuses the directory and says how to declare them. A
432
+ directory holding files this format does not own (a README, a note) is
433
+ emptied of its own and left standing.
434
+ """
435
+ if not collection_dir.is_dir():
436
+ return
437
+ for path in collection_dir.glob("*.yaml"):
438
+ path.unlink()
439
+ keep_file = collection_dir / DIRECTORY_KEEP_FILE
440
+ if keep_file.exists():
441
+ keep_file.unlink()
442
+ if not any(collection_dir.iterdir()):
443
+ collection_dir.rmdir()
347
444
 
348
445
 
349
446
  def read_config_directory(connector_dir: Path, *, report: Report | None = None) -> dict:
@@ -353,6 +450,9 @@ def read_config_directory(connector_dir: Path, *, report: Report | None = None)
353
450
  (see `Normalization`). Commands pass a printer: the CLI says what it
354
451
  changed instead of changing it quietly. Omitting it discards the notes,
355
452
  which is only ever right for a caller that has already been told.
453
+
454
+ A directory that does not declare an optional collection is refused: see
455
+ `OPTIONAL_COLLECTIONS`.
356
456
  """
357
457
  report = report or _ignore
358
458
  config_path = connector_dir / CONFIG_FILE_NAME
@@ -360,16 +460,14 @@ def read_config_directory(connector_dir: Path, *, report: Report | None = None)
360
460
  raise ConfigDirectoryError(f"No {CONFIG_FILE_NAME} found in {connector_dir}")
361
461
 
362
462
  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
463
 
371
464
  for key, dir_name in COLLECTION_DIRS.items():
372
465
  collection_dir = connector_dir / dir_name
466
+ if key in config:
467
+ _read_inline_collection(config, key, dir_name, collection_dir)
468
+ continue
469
+ if key in OPTIONAL_COLLECTIONS and not collection_dir.is_dir():
470
+ raise ConfigDirectoryError(undeclared_collection_message(connector_dir, key))
373
471
  items = [
374
472
  _load_yaml(path, report) for path in sorted(collection_dir.glob("*.yaml"))
375
473
  ] if collection_dir.is_dir() else []
@@ -380,6 +478,89 @@ def read_config_directory(connector_dir: Path, *, report: Report | None = None)
380
478
  return config
381
479
 
382
480
 
481
+ def _read_inline_collection(
482
+ config: dict, key: str, dir_name: str, collection_dir: Path
483
+ ) -> None:
484
+ """A collection plugsync.yaml still carries as a top-level key.
485
+
486
+ For `entities` and `flows` that is always an editing mistake, and ignoring
487
+ it would push a draft with every one of them deleted.
488
+
489
+ For `sources` it is what a CLI older than the `sources/` directory (0.5.0
490
+ and earlier) leaves behind: those releases preserve the key verbatim, so
491
+ the list is read as it stands and the next write splits it into files. What
492
+ is refused is a directory carrying BOTH -- two lists of sources, of which a
493
+ push would send one and delete the other.
494
+ """
495
+ if key not in OPTIONAL_COLLECTIONS:
496
+ raise ConfigDirectoryError(
497
+ f"{CONFIG_FILE_NAME} declares {key!r} inline, but {key} live in "
498
+ f"the {dir_name}/ directory. Move them there, or "
499
+ "re-run 'plugsync pull'."
500
+ )
501
+ if collection_dir.is_dir():
502
+ raise ConfigDirectoryError(
503
+ f"{CONFIG_FILE_NAME} declares {key!r} inline and {dir_name}/ also "
504
+ f"exists: two lists of {key}, and a push replaces the whole draft "
505
+ f"with one of them. Delete the {key!r} key from {CONFIG_FILE_NAME} "
506
+ f"to keep {dir_name}/, or delete {dir_name}/ to keep the key."
507
+ )
508
+ if config[key] is None:
509
+ # `sources:` with nothing after it. Not a declaration: read as an empty
510
+ # list it would turn a config that says nothing about sources into one
511
+ # declaring the connector has none, which is the guess the CLI refuses
512
+ # to make for a missing `sources/` directory too.
513
+ raise ConfigDirectoryError(
514
+ undeclared_collection_message(collection_dir.parent, key)
515
+ )
516
+ items = config[key]
517
+ for item in items:
518
+ _item_name(item, key, CONFIG_FILE_NAME)
519
+ config[key] = sorted(items, key=lambda item: item["name"])
520
+
521
+
522
+ # How the backend names a key inside a named collection item when it refuses a
523
+ # draft write: `sources[hubspot].config.triggers[0].pattern` (ADR-0026), the
524
+ # same shape the entity and flow diffs use.
525
+ #
526
+ # The name arrives quoted from part of the API (`sources['hubspot'] is declared
527
+ # twice`) and bare from the rest, so the quotes are stripped below rather than
528
+ # matched here: they are characters no file name may hold, and leaving them in
529
+ # mangled the path into `_hubspot_.yaml`, a file that does not exist.
530
+ _ITEM_PATH = re.compile(
531
+ r"\b(" + "|".join(COLLECTION_DIRS) + r")\[([^\[\]]+)\](?:\.([\w.\[\]]+))?"
532
+ )
533
+
534
+ # What the quoting above wraps the name in. Stripped, never mangled: a name may
535
+ # legitimately contain neither, so nothing real is lost.
536
+ _NAME_QUOTES = "'\""
537
+
538
+
539
+ def locate_in_directory(message: str) -> list[tuple[str, str]]:
540
+ """Every config path a backend message names, as `(file, key)` pairs.
541
+
542
+ The API rejects a config by the path the key has in the JSON document; the
543
+ operator has a DIRECTORY in front of them. Resolving
544
+ `sources[hubspot].config.triggers[0].pattern` to
545
+ `sources/hubspot.yaml` plus `config.triggers[0].pattern` is the difference
546
+ between a 422 they can act on and one they have to decode by hand -- and
547
+ the file name is mangled here exactly as `write_config_directory` mangles
548
+ it, so a source called `shop/eu` is pointed at the file that really holds
549
+ it.
550
+
551
+ Ordered as they appear in the message, each path reported once.
552
+ """
553
+ return list(
554
+ dict.fromkeys(
555
+ (
556
+ f"{COLLECTION_DIRS[collection]}/{_file_stem(name.strip(_NAME_QUOTES))}.yaml",
557
+ key.rstrip("."),
558
+ )
559
+ for collection, name, key in _ITEM_PATH.findall(message)
560
+ )
561
+ )
562
+
563
+
383
564
  def flows_sharing_a_trigger(config: dict) -> list[tuple[str, str, list[str]]]:
384
565
  """Flow groups whose relative execution order this format does not preserve.
385
566
 
@@ -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,26 +1,26 @@
1
- plugsync_cli/__init__.py,sha256=-DngLLWTSfZYifUzSXxi_knZCypjhnrBbvnwnvb-4HM,75
1
+ plugsync_cli/__init__.py,sha256=PlGlt8CSRA3Ay-FfIqLXUuPNEOUwWaen2HnLvTWZukI,75
2
2
  plugsync_cli/bundler.py,sha256=nCskRqAILHe3LLMZSyS4klBbM879yBvVJODpZvEEm38,2219
3
3
  plugsync_cli/client.py,sha256=3AklXqJAtX4Jo6dk313-_f_oQFpawX2nUwUdaO-lFqM,15059
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=RsKFNuEpKINExjyU-fKng6IIR9L-x8tr4TiCRHgIazI,7810
7
- plugsync_cli/findings.py,sha256=V4A60XI2rtfLTFj7kpGJivVFiUfIhu50N3FdCGRnmbU,5005
6
+ plugsync_cli/context.py,sha256=5sk5_xkuL7LsE0fDitWVsKUArwx18Vyr47mgs-uMrUU,10624
7
+ plugsync_cli/findings.py,sha256=ORWvqV7P8ss4Id846LZwWp5X6xEh7uZl6c86faDXfyQ,5526
8
8
  plugsync_cli/main.py,sha256=z_zM0GXm_tq_ZlNdFqlYuNQOk6cCdxsLagWmN95bAdw,3886
9
9
  plugsync_cli/schema_defaults.py,sha256=tSMmm9cjQlj2YvN7Q20s0rwoHjiOdH6CLttlfbLzmaE,8089
10
- plugsync_cli/serializer.py,sha256=93l_C88PsCj1fPMZMSTLXMY_D-bfFgpvjsDWiWwwJaQ,17353
10
+ plugsync_cli/serializer.py,sha256=Nspm510tbgwI_udiPpNeXjO_NVWL-T1VEC9uvpXYqMM,25620
11
11
  plugsync_cli/commands/__init__.py,sha256=wgYTWnZKXF-4M9ITNDhZMTUJH6_FthcUt_5zGj9NS8I,20
12
12
  plugsync_cli/commands/auth.py,sha256=cAcN8ALfkEOcejB4s4zq19dxS6v6iAw691B0HPTQd-Q,5590
13
13
  plugsync_cli/commands/connector.py,sha256=FzoK7P5m-siCUpYeFkXbCzimMU_UePmbaP-wqcM_DU0,8751
14
- plugsync_cli/commands/diff.py,sha256=9XR6heMWXv3PhBEP9yH2UvgVqBb5UeDXchAvIQhRwpU,17821
14
+ plugsync_cli/commands/diff.py,sha256=zgTA5FeJLPnr-kiQaUezEyF5Wwq_2Gv4qnKnxvNkA7Q,19664
15
15
  plugsync_cli/commands/log.py,sha256=49GBgAvvs07d-OwMEgKKc7WalCKH2xmr5l7Ov6N13jM,3376
16
16
  plugsync_cli/commands/plugin.py,sha256=as1BM6lxpPcdrE12D--BCbSiriCLJbZED8utqJIGos8,20018
17
- plugsync_cli/commands/preview.py,sha256=yAgXnVsViyaHS8uk4akP7OgJEmCyFUmKSbeW2S0AhBs,2534
18
- plugsync_cli/commands/pull.py,sha256=UPD7riTKIo-bCiDqrW2v_Ipsb5AgOQxlOqsEeSvl7nU,2624
19
- plugsync_cli/commands/push.py,sha256=j8yGJ_1j6da_0Xz4UI6im2b3G3_SF8z8VfidPFrnCkg,5160
17
+ plugsync_cli/commands/preview.py,sha256=7MyGDqyb_6XLiO6-CFyNJDBuD2BXqfKg8Y2U3ax2ptM,2960
18
+ plugsync_cli/commands/pull.py,sha256=UXXaINuTHo3sroXAmfA7BZFQj7eG8OKrWCbvhu1Y05o,2479
19
+ plugsync_cli/commands/push.py,sha256=xZNpRY4wAcTnkkw59Tg18lRoYilFCC-t8SR1RvC5CQA,6520
20
20
  plugsync_cli/commands/rollback.py,sha256=OtBk4U7yZubNVwhRmtyX0tSl20oWq8Pcy1Apjj_Ep-0,2471
21
21
  plugsync_cli/commands/validate.py,sha256=1ZjGSSkb21_2URmlyNaBBZXh-MI4L6cU_biKwJ6Itr4,1670
22
- plugsync_cli-0.5.0.dist-info/METADATA,sha256=DxWUCgcZpDbbMo4RFKYLn5mO-LXxuioVzYONtzPfd4g,4377
23
- plugsync_cli-0.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
24
- plugsync_cli-0.5.0.dist-info/entry_points.txt,sha256=qWyWFQxY9W7L0hEwoDFjQjgs7TxJMsETk2nIe8jCA9E,51
25
- plugsync_cli-0.5.0.dist-info/top_level.txt,sha256=tShdp15OlzUttfE_QlFMLIfwQfImdp4YVhmB5rXZCgA,13
26
- plugsync_cli-0.5.0.dist-info/RECORD,,
22
+ plugsync_cli-0.7.0.dist-info/METADATA,sha256=0ME4zUbvMWbTXNShCGxCbFasqd4utL2z-mnrOWKs5AA,4377
23
+ plugsync_cli-0.7.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
24
+ plugsync_cli-0.7.0.dist-info/entry_points.txt,sha256=qWyWFQxY9W7L0hEwoDFjQjgs7TxJMsETk2nIe8jCA9E,51
25
+ plugsync_cli-0.7.0.dist-info/top_level.txt,sha256=tShdp15OlzUttfE_QlFMLIfwQfImdp4YVhmB5rXZCgA,13
26
+ plugsync_cli-0.7.0.dist-info/RECORD,,