plugsync-cli 0.6.0__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/PKG-INFO +1 -1
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/__init__.py +1 -1
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/diff.py +16 -27
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/push.py +30 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/context.py +32 -45
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/serializer.py +54 -24
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/PKG-INFO +1 -1
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/pyproject.toml +1 -1
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_commands.py +195 -75
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_e2e_draft_api.py +25 -8
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_hand_edited_yaml.py +1 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_serializer.py +61 -26
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/README.md +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/bundler.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/client.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/__init__.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/auth.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/connector.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/log.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/plugin.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/preview.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/pull.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/rollback.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/commands/validate.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/compat.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/config.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/findings.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/main.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli/schema_defaults.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/SOURCES.txt +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/requires.txt +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/plugsync_cli.egg-info/top_level.txt +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/setup.cfg +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_api_contract.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_auth_commands.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_client.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_compat.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_config.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_connector_commands.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_plugin_commands.py +0 -0
- {plugsync_cli-0.6.0 → plugsync_cli-0.7.0}/tests/test_schema_defaults.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plugsync-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required
|
|
5
5
|
Author-email: Exelab <hello@plugsync.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""PluSync CLI — git-like connector management."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.7.0"
|
|
@@ -63,7 +63,8 @@ console = Console()
|
|
|
63
63
|
# Top-level config keys compared item-by-item (keyed by name) rather than whole.
|
|
64
64
|
# `sources` reads like the other two here: a published version from before the
|
|
65
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.
|
|
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.
|
|
67
68
|
COLLECTIONS = {"entities": "entity", "flows": "flow", "sources": "source"}
|
|
68
69
|
|
|
69
70
|
# Keys listed in one Detail cell before the rest is summarized. A row exists to
|
|
@@ -235,37 +236,25 @@ def _diff_configs(local: dict, remote: dict, root: SchemaNode) -> list[dict]:
|
|
|
235
236
|
|
|
236
237
|
|
|
237
238
|
def _declaration_change(key: str, local: dict, remote: dict) -> list[dict]:
|
|
238
|
-
"""The row for a collection
|
|
239
|
-
|
|
240
|
-
Only an optional collection can be in that position, and
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
so without this row
|
|
246
|
-
|
|
247
|
-
which document owns the definitions.
|
|
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
248
|
"""
|
|
249
|
-
if key not in OPTIONAL_COLLECTIONS:
|
|
249
|
+
if key not in OPTIONAL_COLLECTIONS or remote.get(key) is not None:
|
|
250
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
251
|
return [
|
|
264
252
|
_simple(
|
|
265
|
-
f"{key}
|
|
253
|
+
f"{key}_key_added",
|
|
266
254
|
key,
|
|
267
|
-
f"declared in
|
|
268
|
-
f"
|
|
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}",
|
|
269
258
|
)
|
|
270
259
|
]
|
|
271
260
|
|
|
@@ -137,7 +137,37 @@ def _publish(client, connector_id: str, message: str | None, republish: bool = F
|
|
|
137
137
|
)
|
|
138
138
|
return
|
|
139
139
|
console.print(f"[green]Published v{result.get('version')}[/green]")
|
|
140
|
+
_reveal_created_secrets(result.get("created_sources") or [])
|
|
140
141
|
warnings = result.get("warnings") or []
|
|
141
142
|
if warnings:
|
|
142
143
|
console.print("\nPublished with warnings:")
|
|
143
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
|
+
)
|
|
@@ -22,6 +22,7 @@ from plugsync_cli.serializer import (
|
|
|
22
22
|
locate_in_directory,
|
|
23
23
|
read_config_directory,
|
|
24
24
|
read_local_state,
|
|
25
|
+
undeclared_collection_message,
|
|
25
26
|
write_config_directory,
|
|
26
27
|
write_local_state,
|
|
27
28
|
)
|
|
@@ -131,17 +132,7 @@ def write_draft_from_directory(
|
|
|
131
132
|
fires every time stops being read exactly when it matters.
|
|
132
133
|
"""
|
|
133
134
|
config = context.config()
|
|
134
|
-
|
|
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
|
-
)
|
|
135
|
+
expected_version = None if force else _expected_draft_version(client, context)
|
|
145
136
|
try:
|
|
146
137
|
draft = client.put_draft(
|
|
147
138
|
context.connector_id, config, expected_version=expected_version
|
|
@@ -153,7 +144,7 @@ def write_draft_from_directory(
|
|
|
153
144
|
"'plugsync push --force' to overwrite theirs."
|
|
154
145
|
)
|
|
155
146
|
except httpx.HTTPStatusError as exc:
|
|
156
|
-
fail(_rejection_pointing_at_the_files(exc))
|
|
147
|
+
fail(_rejection_pointing_at_the_files(exc, context))
|
|
157
148
|
|
|
158
149
|
warn_if_flow_order_is_not_preserved(draft["config"])
|
|
159
150
|
write_config_directory(context.directory, draft["config"])
|
|
@@ -170,7 +161,24 @@ def write_draft_from_directory(
|
|
|
170
161
|
return draft
|
|
171
162
|
|
|
172
163
|
|
|
173
|
-
|
|
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:
|
|
174
182
|
"""The API's refusal, with every config path it names resolved to a file.
|
|
175
183
|
|
|
176
184
|
A rejected draft is described the way the config document reads
|
|
@@ -178,7 +186,12 @@ def _rejection_pointing_at_the_files(exc: httpx.HTTPStatusError) -> str:
|
|
|
178
186
|
operator is looking at it: they have a directory of files open. Naming the
|
|
179
187
|
file that holds the key, alongside the backend's own explanation, is what
|
|
180
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.
|
|
181
192
|
"""
|
|
193
|
+
if _api_error_code(exc) == SOURCES_KEY_REMOVAL_ERROR:
|
|
194
|
+
return undeclared_collection_message(context.directory, "sources")
|
|
182
195
|
message = describe_http_error(exc)
|
|
183
196
|
located = locate_in_directory(message)
|
|
184
197
|
if not located:
|
|
@@ -192,10 +205,10 @@ def _rejection_pointing_at_the_files(exc: httpx.HTTPStatusError) -> str:
|
|
|
192
205
|
def describe_config(config: dict) -> str:
|
|
193
206
|
"""What a config holds, counted, for the line a pull or a push ends on.
|
|
194
207
|
|
|
195
|
-
`sources` is reported only when the config declares the key.
|
|
196
|
-
|
|
197
|
-
is not the same as having none: printing "0 sources" would say
|
|
198
|
-
|
|
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.
|
|
199
212
|
"""
|
|
200
213
|
counted = [
|
|
201
214
|
_counted(config.get("entities"), "entity", "entities"),
|
|
@@ -211,43 +224,17 @@ def _counted(items, singular: str, plural: str) -> str:
|
|
|
211
224
|
return f"{count} {singular if count == 1 else plural}"
|
|
212
225
|
|
|
213
226
|
|
|
214
|
-
def
|
|
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:
|
|
227
|
+
def _expected_draft_version(client: PlugSyncClient, context: ConnectorContext) -> int:
|
|
238
228
|
"""The draft version a write guards itself with.
|
|
239
229
|
|
|
240
230
|
Normally the one recorded at pull time, which is what makes a concurrent
|
|
241
231
|
edit detectable. A directory pulled with `--revision` has none: there the
|
|
242
232
|
current draft version is read now, which still rules out a write racing this
|
|
243
233
|
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.
|
|
247
234
|
"""
|
|
248
235
|
if context.draft_version is not None:
|
|
249
236
|
return context.draft_version
|
|
250
|
-
current =
|
|
237
|
+
current = client.get_draft(context.connector_id)["draft_version"]
|
|
251
238
|
console.print(
|
|
252
239
|
f"[yellow]This directory was pulled from a published version, not the "
|
|
253
240
|
f"draft: guarding against edits after draft v{current} only.[/yellow]"
|
|
@@ -62,23 +62,25 @@ LOCAL_STATE_FILE_NAME = ".plugsync.yaml"
|
|
|
62
62
|
# in plugsync.yaml, mapped to the directory that holds them.
|
|
63
63
|
COLLECTION_DIRS = {"entities": "entities", "flows": "flows", "sources": "sources"}
|
|
64
64
|
|
|
65
|
-
# Collections whose ABSENCE
|
|
65
|
+
# Collections whose ABSENCE is not the same as "empty".
|
|
66
66
|
#
|
|
67
|
-
# `sources` is
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
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.
|
|
76
78
|
OPTIONAL_COLLECTIONS = frozenset({"sources"})
|
|
77
79
|
|
|
78
80
|
# Written into an optional collection's directory when it holds no item, because
|
|
79
81
|
# git carries no empty directory and the directory is what declares the key:
|
|
80
|
-
# without it, pull -> commit -> clone -> push would
|
|
81
|
-
#
|
|
82
|
+
# without it, pull -> commit -> clone -> push would find no `sources/` and
|
|
83
|
+
# refuse a directory that declared `sources: []` when it was pulled.
|
|
82
84
|
DIRECTORY_KEEP_FILE = ".gitkeep"
|
|
83
85
|
|
|
84
86
|
# Characters kept as-is in a file name. Entity and flow names are free-form
|
|
@@ -94,6 +96,25 @@ class ConfigDirectoryError(Exception):
|
|
|
94
96
|
"""The local directory is not a readable connector config."""
|
|
95
97
|
|
|
96
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
|
+
|
|
97
118
|
_TIMESTAMP_TAG = "tag:yaml.org,2002:timestamp"
|
|
98
119
|
|
|
99
120
|
|
|
@@ -317,7 +338,9 @@ def canonical_config(config: dict) -> dict:
|
|
|
317
338
|
empty, so a config read from disk and one read from the API can be compared
|
|
318
339
|
for equality (what `plugsync diff`, `validate` and `preview` do to tell
|
|
319
340
|
local files from the remote draft). An optional collection the config does
|
|
320
|
-
not declare stays undeclared:
|
|
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.
|
|
321
344
|
"""
|
|
322
345
|
canonical = {k: v for k, v in config.items() if k not in COLLECTION_DIRS}
|
|
323
346
|
for key in COLLECTION_DIRS:
|
|
@@ -399,12 +422,15 @@ def _write_collection(
|
|
|
399
422
|
|
|
400
423
|
|
|
401
424
|
def _undeclare_collection(collection_dir: Path) -> None:
|
|
402
|
-
"""Erase an optional collection the config
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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.
|
|
408
434
|
"""
|
|
409
435
|
if not collection_dir.is_dir():
|
|
410
436
|
return
|
|
@@ -424,6 +450,9 @@ def read_config_directory(connector_dir: Path, *, report: Report | None = None)
|
|
|
424
450
|
(see `Normalization`). Commands pass a printer: the CLI says what it
|
|
425
451
|
changed instead of changing it quietly. Omitting it discards the notes,
|
|
426
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`.
|
|
427
456
|
"""
|
|
428
457
|
report = report or _ignore
|
|
429
458
|
config_path = connector_dir / CONFIG_FILE_NAME
|
|
@@ -438,7 +467,7 @@ def read_config_directory(connector_dir: Path, *, report: Report | None = None)
|
|
|
438
467
|
_read_inline_collection(config, key, dir_name, collection_dir)
|
|
439
468
|
continue
|
|
440
469
|
if key in OPTIONAL_COLLECTIONS and not collection_dir.is_dir():
|
|
441
|
-
|
|
470
|
+
raise ConfigDirectoryError(undeclared_collection_message(connector_dir, key))
|
|
442
471
|
items = [
|
|
443
472
|
_load_yaml(path, report) for path in sorted(collection_dir.glob("*.yaml"))
|
|
444
473
|
] if collection_dir.is_dir() else []
|
|
@@ -479,10 +508,11 @@ def _read_inline_collection(
|
|
|
479
508
|
if config[key] is None:
|
|
480
509
|
# `sources:` with nothing after it. Not a declaration: read as an empty
|
|
481
510
|
# list it would turn a config that says nothing about sources into one
|
|
482
|
-
# declaring the connector has none,
|
|
483
|
-
#
|
|
484
|
-
|
|
485
|
-
|
|
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
|
+
)
|
|
486
516
|
items = config[key]
|
|
487
517
|
for item in items:
|
|
488
518
|
_item_name(item, key, CONFIG_FILE_NAME)
|
|
@@ -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
|
|
@@ -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.7.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" }
|
|
@@ -9,6 +9,7 @@ against a real server is `test_e2e_draft_api.py`'s.
|
|
|
9
9
|
import os
|
|
10
10
|
|
|
11
11
|
import httpx
|
|
12
|
+
import pytest
|
|
12
13
|
import yaml
|
|
13
14
|
from click.testing import CliRunner
|
|
14
15
|
from unittest.mock import MagicMock, patch
|
|
@@ -28,6 +29,8 @@ CONFIG = {
|
|
|
28
29
|
"flows": [
|
|
29
30
|
{"name": "shop-orders", "source": "shop", "event_type": "order.created", "steps": []}
|
|
30
31
|
],
|
|
32
|
+
# Every draft the API answers declares the key (ADR-0026), `[]` included.
|
|
33
|
+
"sources": [],
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
|
|
@@ -494,6 +497,55 @@ class TestPush:
|
|
|
494
497
|
assert "envelope_like_path" in result.output
|
|
495
498
|
assert "portal_unreachable" in result.output
|
|
496
499
|
|
|
500
|
+
def test_push_publish_reveals_the_secrets_of_the_sources_it_created(self, tmp_path):
|
|
501
|
+
"""#1570 / UI-42: a `webhook` source declared in the draft is created
|
|
502
|
+
BY the publish, and `created_sources` is the one and only place its
|
|
503
|
+
plaintext secret appears. Swallowing it would leave the operator with a
|
|
504
|
+
live source and no usable secret."""
|
|
505
|
+
directory = _local_dir(tmp_path, "shop")
|
|
506
|
+
client = _mock_client()
|
|
507
|
+
client.publish.return_value = {
|
|
508
|
+
"version": 5,
|
|
509
|
+
"warnings": [],
|
|
510
|
+
"created_sources": [
|
|
511
|
+
{"name": "shop_hook", "secret": "whsec-0123456789abcdef"},
|
|
512
|
+
{"name": "erp_hook", "secret": "whsec-second-value"},
|
|
513
|
+
# No secret to generate: must not print a bare name with an
|
|
514
|
+
# empty value next to two real secrets.
|
|
515
|
+
{"name": "nightly_poller", "secret": None},
|
|
516
|
+
],
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
runner = CliRunner()
|
|
520
|
+
with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
|
|
521
|
+
result = _run_ok(runner, ["push", str(directory), "--publish"])
|
|
522
|
+
|
|
523
|
+
out = _flat(result.output)
|
|
524
|
+
assert "whsec-0123456789abcdef" in out
|
|
525
|
+
assert "whsec-second-value" in out
|
|
526
|
+
assert "shop_hook" in out and "erp_hook" in out
|
|
527
|
+
assert "nightly_poller" not in out
|
|
528
|
+
assert "only once" in out.lower()
|
|
529
|
+
# Unrecoverable by design, so the warning has to name the way back.
|
|
530
|
+
assert "rotate" in out.lower()
|
|
531
|
+
|
|
532
|
+
def test_push_publish_says_nothing_when_no_source_carried_a_secret(self, tmp_path):
|
|
533
|
+
directory = _local_dir(tmp_path, "shop")
|
|
534
|
+
client = _mock_client()
|
|
535
|
+
client.publish.return_value = {
|
|
536
|
+
"version": 5,
|
|
537
|
+
"warnings": [],
|
|
538
|
+
"created_sources": [{"name": "nightly_poller", "secret": None}],
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
runner = CliRunner()
|
|
542
|
+
with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
|
|
543
|
+
result = _run_ok(runner, ["push", str(directory), "--publish"])
|
|
544
|
+
|
|
545
|
+
out = _flat(result.output).lower()
|
|
546
|
+
assert "published v5" in out
|
|
547
|
+
assert "secret" not in out
|
|
548
|
+
|
|
497
549
|
def test_push_publish_refuses_on_a_preflight_error(self, tmp_path):
|
|
498
550
|
directory = _local_dir(tmp_path, "shop")
|
|
499
551
|
client = _mock_client()
|
|
@@ -905,6 +957,7 @@ class TestDiffNamesWhatChanged:
|
|
|
905
957
|
"steps": [{"id": "upsert", "action": "upsert", "target": "hubspot"}],
|
|
906
958
|
}
|
|
907
959
|
],
|
|
960
|
+
"sources": [],
|
|
908
961
|
}
|
|
909
962
|
|
|
910
963
|
@staticmethod
|
|
@@ -929,6 +982,7 @@ class TestDiffNamesWhatChanged:
|
|
|
929
982
|
"steps": [step],
|
|
930
983
|
}
|
|
931
984
|
],
|
|
985
|
+
"sources": [],
|
|
932
986
|
}
|
|
933
987
|
|
|
934
988
|
def test_only_a_materialized_default_is_not_a_change_of_yours(self, tmp_path):
|
|
@@ -1014,6 +1068,7 @@ class TestDiffNamesWhatChanged:
|
|
|
1014
1068
|
}
|
|
1015
1069
|
],
|
|
1016
1070
|
"flows": [],
|
|
1071
|
+
"sources": [],
|
|
1017
1072
|
}
|
|
1018
1073
|
|
|
1019
1074
|
EMAIL = {"source": "$.payload.email", "target": "email"}
|
|
@@ -1221,6 +1276,7 @@ class TestDiffPluginEmbeddedFields:
|
|
|
1221
1276
|
"steps": [step],
|
|
1222
1277
|
}
|
|
1223
1278
|
],
|
|
1279
|
+
"sources": [],
|
|
1224
1280
|
}
|
|
1225
1281
|
|
|
1226
1282
|
def _embedded_step(self, **overrides) -> dict:
|
|
@@ -1491,15 +1547,17 @@ SOURCES = [
|
|
|
1491
1547
|
]
|
|
1492
1548
|
|
|
1493
1549
|
CONFIG_WITH_SOURCES = {**CONFIG, "sources": SOURCES}
|
|
1550
|
+
# A version published before the sources were part of the config: no key.
|
|
1551
|
+
CONFIG_BEFORE_SOURCES = {k: v for k, v in CONFIG.items() if k != "sources"}
|
|
1494
1552
|
|
|
1495
1553
|
|
|
1496
1554
|
class TestSources:
|
|
1497
1555
|
"""The connector directory carries the sources too, losslessly.
|
|
1498
1556
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1557
|
+
Every draft declares the key (ADR-0026), `[]` included, and `[]` means
|
|
1558
|
+
"this connector has no sources": the publish deletes whatever rows it had.
|
|
1559
|
+
So a pull must not invent the key for a version that lacks it, and an
|
|
1560
|
+
empty declaration must survive the round trip as an empty directory.
|
|
1503
1561
|
"""
|
|
1504
1562
|
|
|
1505
1563
|
def test_pull_writes_one_file_per_source_and_counts_them(self, tmp_path):
|
|
@@ -1520,13 +1578,18 @@ class TestSources:
|
|
|
1520
1578
|
assert yaml.safe_load((out / "sources" / "hubspot.yaml").read_text()) == SOURCES[0]
|
|
1521
1579
|
assert "3 sources" in _flat(result.output)
|
|
1522
1580
|
|
|
1523
|
-
def
|
|
1581
|
+
def test_pull_says_nothing_about_sources_when_the_version_has_no_key(self, tmp_path):
|
|
1582
|
+
"""A version published before sources were versioned: "0 sources"
|
|
1583
|
+
would say something that version never said."""
|
|
1524
1584
|
client = _mock_client()
|
|
1525
1585
|
client.find_connector.return_value = {"id": CONNECTOR_ID, "name": "shop"}
|
|
1586
|
+
client.get_version.return_value = _version_response(1, CONFIG_BEFORE_SOURCES)
|
|
1526
1587
|
|
|
1527
1588
|
runner = CliRunner()
|
|
1528
1589
|
with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
|
|
1529
|
-
result = _run_ok(
|
|
1590
|
+
result = _run_ok(
|
|
1591
|
+
runner, ["pull", "shop", "--revision", "1", "-o", str(tmp_path / "shop")]
|
|
1592
|
+
)
|
|
1530
1593
|
|
|
1531
1594
|
assert not (tmp_path / "shop" / "sources").exists()
|
|
1532
1595
|
assert "source" not in _flat(result.output).lower()
|
|
@@ -1557,16 +1620,6 @@ class TestSources:
|
|
|
1557
1620
|
|
|
1558
1621
|
assert client.put_draft.call_args[0][1]["sources"] == []
|
|
1559
1622
|
|
|
1560
|
-
def test_push_does_not_invent_the_key_for_a_directory_without_it(self, tmp_path):
|
|
1561
|
-
directory = _local_dir(tmp_path, "shop")
|
|
1562
|
-
client = _mock_client()
|
|
1563
|
-
|
|
1564
|
-
runner = CliRunner()
|
|
1565
|
-
with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
|
|
1566
|
-
_run_ok(runner, ["push", str(directory)])
|
|
1567
|
-
|
|
1568
|
-
assert "sources" not in client.put_draft.call_args[0][1]
|
|
1569
|
-
|
|
1570
1623
|
def test_a_rejected_source_is_reported_by_file_and_key(self, tmp_path):
|
|
1571
1624
|
"""A 422 names a path in the config; the operator sees a directory."""
|
|
1572
1625
|
directory = _local_dir(tmp_path, "shop", config=CONFIG_WITH_SOURCES)
|
|
@@ -1683,100 +1736,162 @@ class TestSources:
|
|
|
1683
1736
|
assert "sources[hubspot].config.triggers[0].pattern" in flat
|
|
1684
1737
|
|
|
1685
1738
|
|
|
1686
|
-
|
|
1687
|
-
"""
|
|
1739
|
+
def _undeclared_dir(tmp_path, name="shop"):
|
|
1740
|
+
"""A directory that declares no sources: no `sources/`, no inline key.
|
|
1688
1741
|
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
puts the definitions under the connector's config, the other leaves them
|
|
1692
|
-
to whatever manages them outside it -- so the transition needs saying out
|
|
1693
|
-
loud, in `diff` and on the push that performs it.
|
|
1742
|
+
What a CLI 0.5.x pull without inline sources leaves behind, or a clone of
|
|
1743
|
+
a repository where the `.gitkeep` holding up an empty `sources/` was lost.
|
|
1694
1744
|
"""
|
|
1745
|
+
return _local_dir(tmp_path, name, config=CONFIG_BEFORE_SOURCES)
|
|
1695
1746
|
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
)
|
|
1699
|
-
|
|
1747
|
+
|
|
1748
|
+
def _assert_says_how_to_declare_the_sources(output: str, directory) -> None:
|
|
1749
|
+
flat = _flat(output)
|
|
1750
|
+
assert str(directory) in flat
|
|
1751
|
+
assert "does not declare 'sources'" in flat
|
|
1752
|
+
assert "plugsync pull" in flat
|
|
1753
|
+
assert ".gitkeep" in flat
|
|
1754
|
+
assert "sources: []" in flat
|
|
1755
|
+
|
|
1756
|
+
|
|
1757
|
+
class TestUndeclaredSources:
|
|
1758
|
+
"""A directory that does not declare `sources` stops in local (#1630).
|
|
1759
|
+
|
|
1760
|
+
The API refuses a draft write that omits the key (422
|
|
1761
|
+
`sources_key_removal_not_supported`), and `sources: []` in its place would
|
|
1762
|
+
delete every source at the next publish. So the CLI neither sends the
|
|
1763
|
+
directory as it is nor fills the gap: it stops before any request and says
|
|
1764
|
+
how to declare the sources.
|
|
1765
|
+
"""
|
|
1766
|
+
|
|
1767
|
+
@pytest.mark.parametrize(
|
|
1768
|
+
"args",
|
|
1769
|
+
[["push"], ["push", "--force"], ["validate"], ["validate", "--push"], ["preview"], ["diff"]],
|
|
1770
|
+
)
|
|
1771
|
+
def test_the_command_stops_before_touching_the_api(self, tmp_path, args):
|
|
1772
|
+
directory = _undeclared_dir(tmp_path)
|
|
1700
1773
|
client = _mock_client()
|
|
1701
|
-
client.list_revisions.return_value = [{"version": 2, "is_active": True}]
|
|
1702
|
-
client.get_version.return_value = _version_response(2, {**CONFIG, "sources": []})
|
|
1703
1774
|
|
|
1704
1775
|
runner = CliRunner()
|
|
1705
1776
|
with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
|
|
1706
|
-
result =
|
|
1777
|
+
result = runner.invoke(cli, [*args, str(directory)])
|
|
1707
1778
|
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
assert
|
|
1779
|
+
assert result.exit_code == 1, result.output
|
|
1780
|
+
_assert_says_how_to_declare_the_sources(result.output, directory)
|
|
1781
|
+
assert client.method_calls == []
|
|
1782
|
+
|
|
1783
|
+
def test_a_null_inline_key_is_refused_the_same_way(self, tmp_path):
|
|
1784
|
+
directory = _undeclared_dir(tmp_path)
|
|
1785
|
+
config_file = directory / "plugsync.yaml"
|
|
1786
|
+
config_file.write_text(config_file.read_text() + "sources:\n")
|
|
1787
|
+
client = _mock_client()
|
|
1788
|
+
|
|
1789
|
+
runner = CliRunner()
|
|
1790
|
+
with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
|
|
1791
|
+
result = runner.invoke(cli, ["push", str(directory)])
|
|
1792
|
+
|
|
1793
|
+
assert result.exit_code == 1
|
|
1794
|
+
_assert_says_how_to_declare_the_sources(result.output, directory)
|
|
1795
|
+
client.put_draft.assert_not_called()
|
|
1711
1796
|
|
|
1712
|
-
def
|
|
1797
|
+
def test_a_sources_directory_holding_only_the_placeholder_sends_an_empty_list(
|
|
1713
1798
|
self, tmp_path
|
|
1714
1799
|
):
|
|
1715
|
-
directory =
|
|
1800
|
+
directory = _undeclared_dir(tmp_path)
|
|
1801
|
+
(directory / "sources").mkdir()
|
|
1802
|
+
(directory / "sources" / ".gitkeep").touch()
|
|
1716
1803
|
client = _mock_client()
|
|
1717
|
-
client.list_revisions.return_value = [{"version": 2, "is_active": True}]
|
|
1718
|
-
client.get_version.return_value = _version_response(2, CONFIG)
|
|
1719
1804
|
|
|
1720
1805
|
runner = CliRunner()
|
|
1721
1806
|
with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
|
|
1722
|
-
|
|
1807
|
+
_run_ok(runner, ["push", str(directory)])
|
|
1723
1808
|
|
|
1724
|
-
assert "
|
|
1809
|
+
assert client.put_draft.call_args[0][1]["sources"] == []
|
|
1725
1810
|
|
|
1726
|
-
def
|
|
1727
|
-
|
|
1811
|
+
def test_inline_sources_written_by_a_0_5_cli_are_pushed_and_split_into_files(
|
|
1812
|
+
self, tmp_path
|
|
1813
|
+
):
|
|
1814
|
+
directory = _undeclared_dir(tmp_path)
|
|
1815
|
+
config_file = directory / "plugsync.yaml"
|
|
1816
|
+
inline = {**yaml.safe_load(config_file.read_text()), "sources": SOURCES}
|
|
1817
|
+
config_file.write_text(yaml.dump(inline))
|
|
1728
1818
|
client = _mock_client()
|
|
1729
|
-
client.
|
|
1730
|
-
client.get_version.return_value = _version_response(2, {**CONFIG, "sources": []})
|
|
1819
|
+
client.put_draft.return_value = _draft_response(CONFIG_WITH_SOURCES, draft_version=4)
|
|
1731
1820
|
|
|
1732
1821
|
runner = CliRunner()
|
|
1733
1822
|
with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
|
|
1734
|
-
|
|
1823
|
+
_run_ok(runner, ["push", str(directory)])
|
|
1735
1824
|
|
|
1736
|
-
|
|
1825
|
+
sent = client.put_draft.call_args[0][1]
|
|
1826
|
+
assert sent["sources"] == sorted(SOURCES, key=lambda s: s["name"])
|
|
1827
|
+
assert (directory / "sources" / "hubspot.yaml").exists()
|
|
1828
|
+
assert "sources" not in yaml.safe_load(config_file.read_text())
|
|
1737
1829
|
|
|
1738
|
-
def
|
|
1739
|
-
"""
|
|
1830
|
+
def test_the_servers_refusal_is_translated_into_the_directorys_terms(self, tmp_path):
|
|
1831
|
+
"""If the request goes out anyway, the 422 is not printed as JSON."""
|
|
1740
1832
|
directory = _local_dir(tmp_path, "shop")
|
|
1833
|
+
client = _mock_client(
|
|
1834
|
+
put_draft=MagicMock(
|
|
1835
|
+
side_effect=_http_error(
|
|
1836
|
+
422,
|
|
1837
|
+
{
|
|
1838
|
+
"detail": {
|
|
1839
|
+
"error": "sources_key_removal_not_supported",
|
|
1840
|
+
"message": "this draft declares 'sources', and the "
|
|
1841
|
+
"declaration cannot be withdrawn",
|
|
1842
|
+
}
|
|
1843
|
+
},
|
|
1844
|
+
)
|
|
1845
|
+
)
|
|
1846
|
+
)
|
|
1847
|
+
|
|
1848
|
+
runner = CliRunner()
|
|
1849
|
+
with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
|
|
1850
|
+
result = runner.invoke(cli, ["push", str(directory)])
|
|
1851
|
+
|
|
1852
|
+
assert result.exit_code == 1
|
|
1853
|
+
_assert_says_how_to_declare_the_sources(result.output, directory)
|
|
1854
|
+
assert "sources_key_removal_not_supported" not in _flat(result.output)
|
|
1855
|
+
|
|
1856
|
+
def test_diff_never_reports_the_key_as_removed(self, tmp_path):
|
|
1857
|
+
"""The direction that drops the key is an error, not a change."""
|
|
1858
|
+
directory = _undeclared_dir(tmp_path)
|
|
1741
1859
|
client = _mock_client()
|
|
1742
|
-
client.
|
|
1860
|
+
client.list_revisions.return_value = [{"version": 2, "is_active": True}]
|
|
1861
|
+
client.get_version.return_value = _version_response(2, CONFIG)
|
|
1743
1862
|
|
|
1744
1863
|
runner = CliRunner()
|
|
1745
1864
|
with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
|
|
1746
|
-
result =
|
|
1865
|
+
result = runner.invoke(cli, ["diff", str(directory)])
|
|
1747
1866
|
|
|
1748
|
-
|
|
1749
|
-
assert "
|
|
1750
|
-
assert "plugsync pull" in flat
|
|
1867
|
+
assert result.exit_code == 1
|
|
1868
|
+
assert "sources_key_removed" not in _flat(result.output)
|
|
1751
1869
|
|
|
1752
|
-
def
|
|
1870
|
+
def test_diff_says_the_published_version_predates_the_sources(self, tmp_path):
|
|
1871
|
+
"""A version published before sources were versioned has no key; the
|
|
1872
|
+
local files always declare it, so this is the one transition left."""
|
|
1753
1873
|
directory = _local_dir(tmp_path, "shop")
|
|
1754
1874
|
client = _mock_client()
|
|
1875
|
+
client.list_revisions.return_value = [{"version": 2, "is_active": True}]
|
|
1876
|
+
client.get_version.return_value = _version_response(2, CONFIG_BEFORE_SOURCES)
|
|
1755
1877
|
|
|
1756
1878
|
runner = CliRunner()
|
|
1757
1879
|
with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
|
|
1758
|
-
result = _run_ok(runner, ["
|
|
1759
|
-
|
|
1760
|
-
assert "removes" not in _flat(result.output).lower()
|
|
1880
|
+
result = _run_ok(runner, ["diff", str(directory)])
|
|
1761
1881
|
|
|
1762
|
-
|
|
1763
|
-
"""The check costs one request, and only when it can fire.
|
|
1882
|
+
assert "sources_key_added" in _flat(result.output)
|
|
1764
1883
|
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
"""
|
|
1768
|
-
directory = _local_dir(
|
|
1769
|
-
tmp_path,
|
|
1770
|
-
"shop",
|
|
1771
|
-
state={"connector": "shop", "connector_id": CONNECTOR_ID, "source": "version 2"},
|
|
1772
|
-
)
|
|
1884
|
+
def test_diff_is_quiet_when_both_sides_declare_the_key(self, tmp_path):
|
|
1885
|
+
directory = _local_dir(tmp_path, "shop")
|
|
1773
1886
|
client = _mock_client()
|
|
1887
|
+
client.list_revisions.return_value = [{"version": 2, "is_active": True}]
|
|
1888
|
+
client.get_version.return_value = _version_response(2, CONFIG)
|
|
1774
1889
|
|
|
1775
1890
|
runner = CliRunner()
|
|
1776
1891
|
with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
|
|
1777
|
-
_run_ok(runner, ["
|
|
1892
|
+
result = _run_ok(runner, ["diff", str(directory)])
|
|
1778
1893
|
|
|
1779
|
-
|
|
1894
|
+
assert "sources_key" not in _flat(result.output)
|
|
1780
1895
|
|
|
1781
1896
|
def test_push_reads_no_draft_when_the_directory_declares_sources(self, tmp_path):
|
|
1782
1897
|
directory = _local_dir(tmp_path, "shop", config=CONFIG_WITH_SOURCES)
|
|
@@ -1788,21 +1903,21 @@ class TestSourcesKeyTransition:
|
|
|
1788
1903
|
|
|
1789
1904
|
client.get_draft.assert_not_called()
|
|
1790
1905
|
|
|
1791
|
-
def
|
|
1906
|
+
def test_pull_revision_of_a_version_without_the_key_leaves_a_directory_push_refuses(
|
|
1792
1907
|
self, tmp_path
|
|
1793
1908
|
):
|
|
1794
1909
|
"""Documented asymmetry: `pull --revision` + `push` is not `rollback`.
|
|
1795
1910
|
|
|
1796
1911
|
A version published before the sources were part of the config carries
|
|
1797
|
-
no key, and a pull SYNCS the directory to what it pulled
|
|
1798
|
-
`sources/` goes
|
|
1799
|
-
|
|
1800
|
-
|
|
1912
|
+
no key, and a pull SYNCS the directory to what it pulled, so the local
|
|
1913
|
+
`sources/` goes. Pushing that directory would ask the server to drop
|
|
1914
|
+
the key, so it is refused in local; `rollback`, restoring the whole
|
|
1915
|
+
blob into the draft, is the way back to that version.
|
|
1801
1916
|
"""
|
|
1802
1917
|
client = _mock_client()
|
|
1803
1918
|
client.find_connector.return_value = {"id": CONNECTOR_ID, "name": "shop"}
|
|
1804
1919
|
client.get_draft.return_value = _draft_response(CONFIG_WITH_SOURCES)
|
|
1805
|
-
client.get_version.return_value = _version_response(1,
|
|
1920
|
+
client.get_version.return_value = _version_response(1, CONFIG_BEFORE_SOURCES)
|
|
1806
1921
|
out = tmp_path / "shop"
|
|
1807
1922
|
|
|
1808
1923
|
runner = CliRunner()
|
|
@@ -1811,5 +1926,10 @@ class TestSourcesKeyTransition:
|
|
|
1811
1926
|
assert (out / "sources").is_dir()
|
|
1812
1927
|
|
|
1813
1928
|
_run_ok(runner, ["pull", "shop", "--revision", "1", "-o", str(out)])
|
|
1929
|
+
assert not (out / "sources").exists()
|
|
1814
1930
|
|
|
1815
|
-
|
|
1931
|
+
result = runner.invoke(cli, ["push", str(out)])
|
|
1932
|
+
|
|
1933
|
+
assert result.exit_code == 1
|
|
1934
|
+
_assert_says_how_to_declare_the_sources(result.output, out)
|
|
1935
|
+
client.put_draft.assert_not_called()
|
|
@@ -415,7 +415,15 @@ def test_second_pull_carries_a_deletion_made_elsewhere(live_backend, connector,
|
|
|
415
415
|
assert [e["name"] for e in live_backend.get(draft_path)["config"]["entities"]] == ["contacts"]
|
|
416
416
|
|
|
417
417
|
# Somebody else drops the entity, through the same API the dashboard uses.
|
|
418
|
-
|
|
418
|
+
# The `sources` declaration is carried over from the stored config rather
|
|
419
|
+
# than retyped: a body that omits the key on a draft that declares it is
|
|
420
|
+
# refused with `sources_key_removal_not_supported` (#1541), and this test
|
|
421
|
+
# is about the entity deletion, not about withdrawing the declaration.
|
|
422
|
+
stored = live_backend.get(draft_path)["config"]
|
|
423
|
+
edited = {"version": "3.1", "entities": [], "flows": []}
|
|
424
|
+
if "sources" in stored:
|
|
425
|
+
edited["sources"] = stored["sources"]
|
|
426
|
+
live_backend.put(draft_path, edited)
|
|
419
427
|
assert live_backend.get(draft_path)["config"]["entities"] == []
|
|
420
428
|
|
|
421
429
|
_run(runner, ["pull", connector["name"], "-o", str(workdir)])
|
|
@@ -629,19 +637,28 @@ def test_sources_survive_a_pull_push_round_trip(live_backend, connector, cli_env
|
|
|
629
637
|
|
|
630
638
|
The round trip is the whole promise of `sources/`: an operator who pulls a
|
|
631
639
|
connector and pushes it back untouched must end up with the draft they
|
|
632
|
-
started from, sources included. The `sources` key is
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
640
|
+
started from, sources included. The `sources` key is mandatory in a draft
|
|
641
|
+
(ADR-0026), and `sources: []` deletes the connector's sources at publish,
|
|
642
|
+
so the empty declaration must travel as itself, never as a missing key.
|
|
643
|
+
|
|
644
|
+
Since #1541 a draft the backend materializes is born declaring the sources
|
|
645
|
+
the connector already has, which for a connector with no rows is the empty
|
|
646
|
+
declaration `sources: []`. The directory has to carry that distinction: an
|
|
647
|
+
EMPTY `sources/` directory means "declares zero sources", and the push must
|
|
648
|
+
send `sources: []` back rather than omit the key - omitting it is refused
|
|
649
|
+
with `sources_key_removal_not_supported`.
|
|
636
650
|
"""
|
|
637
651
|
runner, workdir = cli_env, tmp_path / connector["name"]
|
|
638
652
|
draft_path = f"/api/connectors/{connector['id']}/draft"
|
|
639
653
|
|
|
640
|
-
#
|
|
654
|
+
# The empty declaration survives a pull as an empty directory, and a push
|
|
655
|
+
# sends it back as an empty list, not as an absent key.
|
|
641
656
|
_run(runner, ["pull", connector["name"], "-o", str(workdir)])
|
|
642
|
-
assert
|
|
657
|
+
assert live_backend.get(draft_path)["config"]["sources"] == []
|
|
658
|
+
assert (workdir / "sources").is_dir()
|
|
659
|
+
assert list((workdir / "sources").glob("*.yaml")) == []
|
|
643
660
|
_run(runner, ["push", str(workdir)])
|
|
644
|
-
assert
|
|
661
|
+
assert live_backend.get(draft_path)["config"]["sources"] == []
|
|
645
662
|
|
|
646
663
|
# Declare them as files, exactly as a config-as-code repo would.
|
|
647
664
|
_write_sources(workdir, E2E_SOURCES)
|
|
@@ -57,6 +57,7 @@ def _read(tmp_path, plugsync_yaml: str, notes: list | None = None) -> dict:
|
|
|
57
57
|
directory = tmp_path / "conn"
|
|
58
58
|
directory.mkdir()
|
|
59
59
|
(directory / "plugsync.yaml").write_text(plugsync_yaml)
|
|
60
|
+
(directory / "sources").mkdir()
|
|
60
61
|
return read_config_directory(
|
|
61
62
|
directory, report=None if notes is None else notes.append
|
|
62
63
|
)
|
|
@@ -46,6 +46,8 @@ FULL_CONFIG = {
|
|
|
46
46
|
"unknown_flow_key": ["kept"],
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
+
# Mandatory in every config the API answers (ADR-0026).
|
|
50
|
+
"sources": [],
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
|
|
@@ -82,8 +84,8 @@ def test_layout_is_one_readable_file_per_named_item(tmp_path):
|
|
|
82
84
|
assert "flows" not in top_level
|
|
83
85
|
|
|
84
86
|
|
|
85
|
-
def
|
|
86
|
-
write_config_directory(tmp_path / "conn", {"version": "3.1"})
|
|
87
|
+
def test_empty_config_round_trips_with_every_collection_present(tmp_path):
|
|
88
|
+
write_config_directory(tmp_path / "conn", {"version": "3.1", "sources": []})
|
|
87
89
|
|
|
88
90
|
# An empty collection is not written as an empty directory (git would drop
|
|
89
91
|
# it anyway), and reads back as the empty list the backend defaults to.
|
|
@@ -92,6 +94,7 @@ def test_empty_config_round_trips_with_both_collections_present(tmp_path):
|
|
|
92
94
|
"version": "3.1",
|
|
93
95
|
"entities": [],
|
|
94
96
|
"flows": [],
|
|
97
|
+
"sources": [],
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
|
|
@@ -141,6 +144,7 @@ def test_names_that_are_not_valid_filenames_still_round_trip(tmp_path):
|
|
|
141
144
|
"version": "3.1",
|
|
142
145
|
"entities": [{"name": "shop/orders v2", "schemas": {}}],
|
|
143
146
|
"flows": [],
|
|
147
|
+
"sources": [],
|
|
144
148
|
}
|
|
145
149
|
write_config_directory(tmp_path / "conn", config)
|
|
146
150
|
|
|
@@ -195,6 +199,7 @@ def test_non_ascii_is_written_as_text_not_escapes(tmp_path):
|
|
|
195
199
|
"version": "3.1",
|
|
196
200
|
"settings": {"label": label},
|
|
197
201
|
"entities": [{"name": "citta", "schemas": {}, "note": "perché sì"}],
|
|
202
|
+
"sources": [],
|
|
198
203
|
}
|
|
199
204
|
write_config_directory(tmp_path / "conn", config)
|
|
200
205
|
|
|
@@ -241,11 +246,12 @@ def test_missing_local_state_points_at_pull(tmp_path):
|
|
|
241
246
|
|
|
242
247
|
|
|
243
248
|
# `sources` is the one collection whose ABSENCE is not an empty list (ADR-0026):
|
|
244
|
-
#
|
|
245
|
-
#
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
# declared.
|
|
249
|
+
# `sources: []` declares "this connector has no sources", while a directory
|
|
250
|
+
# that declares nothing is refused on read instead of guessed at -- the API
|
|
251
|
+
# refuses a draft that omits the key, and `[]` in its place would delete every
|
|
252
|
+
# source at the next publish. It is the `sources/` directory itself that says
|
|
253
|
+
# the key is declared.
|
|
254
|
+
WITHOUT_SOURCES = {k: v for k, v in FULL_CONFIG.items() if k != "sources"}
|
|
249
255
|
THREE_SOURCES = [
|
|
250
256
|
{"name": "hubspot", "type": "hubspot", "config": {"triggers": [{"property": "email"}]}},
|
|
251
257
|
{"name": "shop", "type": "event_api", "config": {}},
|
|
@@ -273,13 +279,34 @@ class TestSources:
|
|
|
273
279
|
top_level = yaml.safe_load((tmp_path / "conn" / "plugsync.yaml").read_text())
|
|
274
280
|
assert "sources" not in top_level
|
|
275
281
|
|
|
276
|
-
def
|
|
277
|
-
"""
|
|
278
|
-
|
|
282
|
+
def test_a_version_without_the_key_does_not_grow_one(self, tmp_path):
|
|
283
|
+
"""A version published before sources were versioned has no key.
|
|
284
|
+
|
|
285
|
+
Written as it is, and compared as it is: inventing `sources: []` for it
|
|
286
|
+
would describe it as declaring something it never said.
|
|
287
|
+
"""
|
|
288
|
+
write_config_directory(tmp_path / "conn", WITHOUT_SOURCES)
|
|
279
289
|
|
|
280
290
|
assert not (tmp_path / "conn" / "sources").exists()
|
|
281
|
-
assert "sources" not in
|
|
282
|
-
|
|
291
|
+
assert "sources" not in canonical_config(WITHOUT_SOURCES)
|
|
292
|
+
|
|
293
|
+
def test_a_directory_that_does_not_declare_the_sources_is_refused(self, tmp_path):
|
|
294
|
+
"""No `sources/` and no inline key: stop, and say how to declare them.
|
|
295
|
+
|
|
296
|
+
Reading it as `sources: []` would turn "the directory is missing" into
|
|
297
|
+
"delete every source"; leaving the key out would earn a 422 from the
|
|
298
|
+
server that says nothing about the directory.
|
|
299
|
+
"""
|
|
300
|
+
write_config_directory(tmp_path / "conn", WITHOUT_SOURCES)
|
|
301
|
+
|
|
302
|
+
with pytest.raises(ConfigDirectoryError) as refused:
|
|
303
|
+
read_config_directory(tmp_path / "conn")
|
|
304
|
+
|
|
305
|
+
message = str(refused.value)
|
|
306
|
+
assert str(tmp_path / "conn") in message
|
|
307
|
+
assert "plugsync pull" in message
|
|
308
|
+
assert "sources/" in message and ".gitkeep" in message
|
|
309
|
+
assert "sources: []" in message
|
|
283
310
|
|
|
284
311
|
def test_an_empty_list_is_kept_apart_from_the_key_being_absent(self, tmp_path):
|
|
285
312
|
write_config_directory(tmp_path / "conn", {**FULL_CONFIG, "sources": []})
|
|
@@ -287,11 +314,20 @@ class TestSources:
|
|
|
287
314
|
assert (tmp_path / "conn" / "sources").is_dir()
|
|
288
315
|
assert read_config_directory(tmp_path / "conn")["sources"] == []
|
|
289
316
|
|
|
317
|
+
def test_a_sources_directory_holding_only_the_placeholder_reads_empty(self, tmp_path):
|
|
318
|
+
"""What a clone of a committed "no sources" directory looks like."""
|
|
319
|
+
conn = tmp_path / "conn"
|
|
320
|
+
write_config_directory(conn, WITHOUT_SOURCES)
|
|
321
|
+
(conn / "sources").mkdir()
|
|
322
|
+
(conn / "sources" / ".gitkeep").touch()
|
|
323
|
+
|
|
324
|
+
assert read_config_directory(conn)["sources"] == []
|
|
325
|
+
|
|
290
326
|
def test_an_empty_sources_directory_survives_a_commit(self, tmp_path):
|
|
291
327
|
"""git carries no empty directory, and the directory IS the key here.
|
|
292
328
|
|
|
293
|
-
Without a file in it, pull -> commit -> clone -> push would
|
|
294
|
-
|
|
329
|
+
Without a file in it, pull -> commit -> clone -> push would find no
|
|
330
|
+
`sources/` and refuse a directory that declared `sources: []`.
|
|
295
331
|
"""
|
|
296
332
|
write_config_directory(tmp_path / "conn", {**FULL_CONFIG, "sources": []})
|
|
297
333
|
|
|
@@ -316,14 +352,16 @@ class TestSources:
|
|
|
316
352
|
"hubspot"
|
|
317
353
|
]
|
|
318
354
|
|
|
319
|
-
def
|
|
320
|
-
"""Otherwise the next read would declare
|
|
355
|
+
def test_writing_a_version_without_the_key_removes_the_directory(self, tmp_path):
|
|
356
|
+
"""Otherwise the next read would declare `sources: []` for a version
|
|
357
|
+
that says nothing about sources; without it, the read refuses instead."""
|
|
321
358
|
write_config_directory(tmp_path / "conn", {**FULL_CONFIG, "sources": THREE_SOURCES})
|
|
322
359
|
|
|
323
|
-
write_config_directory(tmp_path / "conn",
|
|
360
|
+
write_config_directory(tmp_path / "conn", WITHOUT_SOURCES)
|
|
324
361
|
|
|
325
362
|
assert not (tmp_path / "conn" / "sources").exists()
|
|
326
|
-
|
|
363
|
+
with pytest.raises(ConfigDirectoryError, match="does not declare 'sources'"):
|
|
364
|
+
read_config_directory(tmp_path / "conn")
|
|
327
365
|
|
|
328
366
|
def test_sources_written_inline_by_an_older_cli_are_read(self, tmp_path):
|
|
329
367
|
"""0.5.0 and earlier round-trip `sources` as an opaque top-level key."""
|
|
@@ -360,22 +398,19 @@ class TestSources:
|
|
|
360
398
|
with pytest.raises(ConfigDirectoryError, match="sources/"):
|
|
361
399
|
read_config_directory(conn)
|
|
362
400
|
|
|
363
|
-
def
|
|
401
|
+
def test_a_null_key_is_refused_like_an_absent_one(self, tmp_path):
|
|
364
402
|
"""`sources:` with nothing after it is not a declaration.
|
|
365
403
|
|
|
366
404
|
Read as an empty list it would turn a config that says nothing about
|
|
367
|
-
sources into one that declares the connector has none
|
|
368
|
-
|
|
369
|
-
reading it as `[]` also made the two disagree on the same document.
|
|
405
|
+
sources into one that declares the connector has none: the same guess
|
|
406
|
+
the CLI refuses to make for a missing `sources/` directory.
|
|
370
407
|
"""
|
|
371
408
|
conn = tmp_path / "conn"
|
|
372
409
|
conn.mkdir()
|
|
373
410
|
(conn / "plugsync.yaml").write_text("version: '3.1'\nsources:\n")
|
|
374
411
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
assert "sources" not in config
|
|
378
|
-
assert "sources" not in canonical_config(config)
|
|
412
|
+
with pytest.raises(ConfigDirectoryError, match="does not declare 'sources'"):
|
|
413
|
+
read_config_directory(conn)
|
|
379
414
|
|
|
380
415
|
def test_an_unnamed_source_file_is_refused(self, tmp_path):
|
|
381
416
|
conn = tmp_path / "conn"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|