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.
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/PKG-INFO +1 -1
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/__init__.py +1 -1
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/diff.py +49 -2
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/preview.py +12 -4
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/pull.py +7 -7
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/push.py +8 -5
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/context.py +87 -5
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/findings.py +13 -4
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/serializer.py +182 -31
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/PKG-INFO +1 -1
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/pyproject.toml +1 -1
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_commands.py +333 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_e2e_draft_api.py +91 -0
- plugsync_cli-0.6.0/tests/test_serializer.py +445 -0
- plugsync_cli-0.5.0/tests/test_serializer.py +0 -239
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/README.md +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/bundler.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/client.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/__init__.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/auth.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/connector.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/log.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/plugin.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/rollback.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/commands/validate.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/compat.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/config.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/main.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli/schema_defaults.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/SOURCES.txt +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/requires.txt +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/plugsync_cli.egg-info/top_level.txt +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/setup.cfg +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_api_contract.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_auth_commands.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_client.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_compat.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_config.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_connector_commands.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_hand_edited_yaml.py +0 -0
- {plugsync_cli-0.5.0 → plugsync_cli-0.6.0}/tests/test_plugin_commands.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
@@ -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)}")
|
|
@@ -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:
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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,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
|
-
|
|
295
|
-
one read from the API can be compared
|
|
296
|
-
`validate` and `preview` do to tell
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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.
|
|
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.
|
|
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" }
|