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 +1 -1
- plugsync_cli/commands/diff.py +38 -2
- plugsync_cli/commands/preview.py +12 -4
- plugsync_cli/commands/pull.py +7 -7
- plugsync_cli/commands/push.py +38 -5
- plugsync_cli/context.py +71 -2
- plugsync_cli/findings.py +13 -4
- plugsync_cli/serializer.py +212 -31
- {plugsync_cli-0.5.0.dist-info → plugsync_cli-0.7.0.dist-info}/METADATA +1 -1
- {plugsync_cli-0.5.0.dist-info → plugsync_cli-0.7.0.dist-info}/RECORD +13 -13
- {plugsync_cli-0.5.0.dist-info → plugsync_cli-0.7.0.dist-info}/WHEEL +0 -0
- {plugsync_cli-0.5.0.dist-info → plugsync_cli-0.7.0.dist-info}/entry_points.txt +0 -0
- {plugsync_cli-0.5.0.dist-info → plugsync_cli-0.7.0.dist-info}/top_level.txt +0 -0
plugsync_cli/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""PluSync CLI — git-like connector management."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.7.0"
|
plugsync_cli/commands/diff.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
plugsync_cli/commands/preview.py
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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))
|
plugsync_cli/commands/pull.py
CHANGED
|
@@ -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
|
|
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)}")
|
plugsync_cli/commands/push.py
CHANGED
|
@@ -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
|
|
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"{
|
|
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,
|
|
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:
|
plugsync_cli/serializer.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
295
|
-
one read from the API can be compared
|
|
296
|
-
`validate` and `preview` do to tell
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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.
|
|
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
|
|
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=
|
|
7
|
-
plugsync_cli/findings.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
18
|
-
plugsync_cli/commands/pull.py,sha256=
|
|
19
|
-
plugsync_cli/commands/push.py,sha256=
|
|
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.
|
|
23
|
-
plugsync_cli-0.
|
|
24
|
-
plugsync_cli-0.
|
|
25
|
-
plugsync_cli-0.
|
|
26
|
-
plugsync_cli-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|