plugsync-cli 0.3.0__py3-none-any.whl → 0.4.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/client.py +51 -4
- plugsync_cli/commands/diff.py +339 -22
- plugsync_cli/commands/plugin.py +94 -7
- plugsync_cli/commands/push.py +33 -4
- plugsync_cli/context.py +10 -1
- plugsync_cli/schema_defaults.py +183 -0
- plugsync_cli/serializer.py +202 -7
- {plugsync_cli-0.3.0.dist-info → plugsync_cli-0.4.0.dist-info}/METADATA +1 -1
- {plugsync_cli-0.3.0.dist-info → plugsync_cli-0.4.0.dist-info}/RECORD +13 -12
- {plugsync_cli-0.3.0.dist-info → plugsync_cli-0.4.0.dist-info}/WHEEL +1 -1
- {plugsync_cli-0.3.0.dist-info → plugsync_cli-0.4.0.dist-info}/entry_points.txt +0 -0
- {plugsync_cli-0.3.0.dist-info → plugsync_cli-0.4.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.4.0"
|
plugsync_cli/client.py
CHANGED
|
@@ -154,6 +154,19 @@ class PlugSyncClient:
|
|
|
154
154
|
r.raise_for_status()
|
|
155
155
|
return r.json()
|
|
156
156
|
|
|
157
|
+
def config_surface(self) -> dict:
|
|
158
|
+
"""The config catalogue: actions, transforms, when, sources, json_schema.
|
|
159
|
+
|
|
160
|
+
`json_schema` is `ConnectorConfigV3.model_json_schema()`, defaults
|
|
161
|
+
included: it is how `plugsync diff` knows which values the server wrote
|
|
162
|
+
from a schema default rather than the operator (#1259). Org-scoped
|
|
163
|
+
auth only -- the catalogue itself is the same for every org.
|
|
164
|
+
"""
|
|
165
|
+
with self._client() as c:
|
|
166
|
+
r = c.get(self._url("/config-surface"))
|
|
167
|
+
r.raise_for_status()
|
|
168
|
+
return r.json()
|
|
169
|
+
|
|
157
170
|
def get_version(self, connector_id: str, version: int) -> dict:
|
|
158
171
|
"""One published config version, config included (backend VersionResponse)."""
|
|
159
172
|
with self._client() as c:
|
|
@@ -175,11 +188,19 @@ class PlugSyncClient:
|
|
|
175
188
|
r.raise_for_status()
|
|
176
189
|
return r.json()
|
|
177
190
|
|
|
178
|
-
def publish(
|
|
191
|
+
def publish(
|
|
192
|
+
self, connector_id: str, change_summary: str | None = None, force: bool = False,
|
|
193
|
+
) -> dict:
|
|
194
|
+
"""`force` (#1367, DX-61): publish a new version even when the draft
|
|
195
|
+
is identical to the active one - the values a publish resolves (plugin
|
|
196
|
+
ARN/version, HubSpot schema) can drift without any draft edit. Unrelated
|
|
197
|
+
to `plugsync push --force`, which discards a concurrent DRAFT edit."""
|
|
179
198
|
with self._client() as c:
|
|
180
199
|
body = {}
|
|
181
200
|
if change_summary:
|
|
182
201
|
body["change_summary"] = change_summary
|
|
202
|
+
if force:
|
|
203
|
+
body["force"] = True
|
|
183
204
|
r = c.post(self._url(f"/connectors/{connector_id}/publish"), json=body)
|
|
184
205
|
r.raise_for_status()
|
|
185
206
|
return r.json()
|
|
@@ -255,12 +276,38 @@ class PlugSyncClient:
|
|
|
255
276
|
r.raise_for_status()
|
|
256
277
|
return r.json()
|
|
257
278
|
|
|
258
|
-
def plugin_invoke(
|
|
259
|
-
|
|
279
|
+
def plugin_invoke(
|
|
280
|
+
self,
|
|
281
|
+
plugin_id: str,
|
|
282
|
+
payload: dict,
|
|
283
|
+
config: dict | None = None,
|
|
284
|
+
auth_credential: str | None = None,
|
|
285
|
+
auth_key: str | None = None,
|
|
286
|
+
) -> dict:
|
|
287
|
+
"""Proxy an invocation to the plugin's Lambda function.
|
|
288
|
+
|
|
289
|
+
`payload` is the event BODY only -- it lands at `event.payload` on
|
|
290
|
+
the Lambda side, not the whole PluginPayload envelope (issue #1365).
|
|
291
|
+
`config` mirrors `event.config`.
|
|
292
|
+
|
|
293
|
+
`auth_credential` is a name, never a secret in the clear: the server
|
|
294
|
+
reads it as an event source name and resolves that source's outbound
|
|
295
|
+
auth exactly as a real flow does (its credential, auth_type and
|
|
296
|
+
auth_header), falling back to a stored credential name when the org
|
|
297
|
+
has no source called that. The event.auth KEY those headers land
|
|
298
|
+
under is `auth_key` when given, else `auth_credential` itself; that
|
|
299
|
+
key is also the `auth` capability grant the server requires on the
|
|
300
|
+
plugin, mirroring what the runtime hands a plugin.
|
|
301
|
+
"""
|
|
260
302
|
with self._client() as c:
|
|
261
303
|
r = c.post(
|
|
262
304
|
self._url(f"/plugins/{plugin_id}/invoke"),
|
|
263
|
-
json={
|
|
305
|
+
json={
|
|
306
|
+
"payload": payload,
|
|
307
|
+
"config": config or {},
|
|
308
|
+
"auth_credential": auth_credential,
|
|
309
|
+
"auth_key": auth_key,
|
|
310
|
+
},
|
|
264
311
|
)
|
|
265
312
|
r.raise_for_status()
|
|
266
313
|
return r.json()
|
plugsync_cli/commands/diff.py
CHANGED
|
@@ -8,20 +8,63 @@ archive -- so the local state was always empty, and an empty local state
|
|
|
8
8
|
diffs clean against anything. Both sides now compare config blobs directly:
|
|
9
9
|
the local directory as `serializer.read_config_directory` returns it, the
|
|
10
10
|
published one as `GET /versions/{v}` returns it. No zip in between.
|
|
11
|
+
|
|
12
|
+
Then the opposite failure (#1259): the comparison was `json.dumps(sort_keys=
|
|
13
|
+
True)` per item and the answer was the literal string "differs", so a config
|
|
14
|
+
whose only divergence was `on_target_deleted: "skip"` -- a schema default the
|
|
15
|
+
server materializes on every draft write -- read as "six entities modified" to
|
|
16
|
+
an operator deciding whether to publish. A false six is worse than silence.
|
|
17
|
+
|
|
18
|
+
So the diff is now field-level: it names the keys that differ, with the nested
|
|
19
|
+
path for the ones inside `schemas`/`steps`, and it separates the fields that
|
|
20
|
+
exist only on the server AND hold that field's schema default. Those are the
|
|
21
|
+
server's doing, not the operator's, so they are reported apart and left out of
|
|
22
|
+
the change count. The defaults come from the schema itself (see
|
|
23
|
+
`plugsync_cli.schema_defaults`), never from a list maintained here.
|
|
24
|
+
|
|
25
|
+
A third bug of the same shape (#1366): a flow with a `plugin` step publishes
|
|
26
|
+
with `function_arn`/`version`/`capabilities` resolved and embedded into
|
|
27
|
+
`step["plugin"]` (`app/services/plugin_resolver.py`), fields the authored
|
|
28
|
+
local form never has. Compared raw, that flow reads as permanently modified,
|
|
29
|
+
forever, even with zero edits. So the published side's plugin steps are
|
|
30
|
+
normalized against the matching local step (by step id) before the field-level
|
|
31
|
+
diff runs, stripping exactly those fields when the local step does not
|
|
32
|
+
declare them -- the same normalization `publish_service.compute_diff` already
|
|
33
|
+
does for the dashboard's draft/version diff (#690), on the same field list,
|
|
34
|
+
read from the same `GET /api/config-surface` (`plugin_embedded_fields`, see
|
|
35
|
+
`plugsync_cli.schema_defaults.plugin_embedded_fields`). A genuine drift (both
|
|
36
|
+
sides declaring the field with different values, e.g. `pull --revision`'d
|
|
37
|
+
files diffed after a plugin re-promotion) is untouched by this and still
|
|
38
|
+
surfaces as a normal change.
|
|
39
|
+
|
|
40
|
+
Read-only throughout, and no diff result is a failure: whatever this command
|
|
41
|
+
finds -- changes, materialized defaults, nothing at all -- it exits 0, because
|
|
42
|
+
it describes and never gates. `plugsync validate` is the one that fails a
|
|
43
|
+
pipeline. A non-zero exit still comes from not being able to run at all (no
|
|
44
|
+
`.plugsync.yaml`, an unreadable version, an API error), exactly as before #1259:
|
|
45
|
+
nothing here changes which situations exit non-zero.
|
|
11
46
|
"""
|
|
47
|
+
import copy
|
|
12
48
|
import json
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from typing import Any
|
|
13
51
|
|
|
14
52
|
import click
|
|
15
53
|
from rich.console import Console
|
|
16
54
|
from rich.table import Table
|
|
17
55
|
|
|
18
56
|
from plugsync_cli.context import build_client, fail, load_context
|
|
57
|
+
from plugsync_cli.schema_defaults import SchemaNode, plugin_embedded_fields, schema_root
|
|
19
58
|
|
|
20
59
|
console = Console()
|
|
21
60
|
|
|
22
61
|
# Top-level config keys compared item-by-item (keyed by name) rather than whole.
|
|
23
62
|
COLLECTIONS = {"entities": "entity", "flows": "flow"}
|
|
24
63
|
|
|
64
|
+
# Keys listed in one Detail cell before the rest is summarized. A row exists to
|
|
65
|
+
# tell an operator where to look, and forty paths in a table cell do not.
|
|
66
|
+
MAX_LISTED_KEYS = 8
|
|
67
|
+
|
|
25
68
|
|
|
26
69
|
def _by_name(items) -> dict[str, dict]:
|
|
27
70
|
return {item["name"]: item for item in items or []}
|
|
@@ -31,50 +74,308 @@ def _differs(left, right) -> bool:
|
|
|
31
74
|
return json.dumps(left, sort_keys=True) != json.dumps(right, sort_keys=True)
|
|
32
75
|
|
|
33
76
|
|
|
34
|
-
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class FieldChange:
|
|
79
|
+
"""One key whose value is not the same on both sides.
|
|
80
|
+
|
|
81
|
+
`path` is written the way the config reads it -- `schemas.hubspot.
|
|
82
|
+
on_target_deleted`, `steps[2].action` -- so it can be followed straight
|
|
83
|
+
into the YAML file the row names.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
path: str
|
|
87
|
+
# The published value, carried only for a server default: the row prints it
|
|
88
|
+
# so the operator can see it is the default and not something surprising.
|
|
89
|
+
value: Any
|
|
90
|
+
# Absent from the local files, present in the published config. A superset
|
|
91
|
+
# of `server_default`: a key can be missing locally for other reasons.
|
|
92
|
+
only_on_server: bool
|
|
93
|
+
server_default: bool
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _field_changes(
|
|
97
|
+
local: Any, remote: Any, node: SchemaNode, path: str = ""
|
|
98
|
+
) -> list[FieldChange]:
|
|
99
|
+
"""Every leaf that differs between two config fragments, named by path.
|
|
100
|
+
|
|
101
|
+
Lists are walked by index only when both sides are the same length: with a
|
|
102
|
+
different length there is no honest pairing (an inserted step would report
|
|
103
|
+
every later one as modified), so the list is reported as one change.
|
|
104
|
+
"""
|
|
105
|
+
if isinstance(local, dict) and isinstance(remote, dict):
|
|
106
|
+
changes: list[FieldChange] = []
|
|
107
|
+
for key in sorted(set(local) | set(remote)):
|
|
108
|
+
here = f"{path}.{key}" if path else key
|
|
109
|
+
if key not in local:
|
|
110
|
+
changes.append(_absent_locally(key, remote[key], node, here))
|
|
111
|
+
elif key not in remote:
|
|
112
|
+
changes.append(_changed(here))
|
|
113
|
+
else:
|
|
114
|
+
changes.extend(
|
|
115
|
+
_field_changes(local[key], remote[key], node.child(key), here)
|
|
116
|
+
)
|
|
117
|
+
return changes
|
|
118
|
+
|
|
119
|
+
if isinstance(local, list) and isinstance(remote, list) and len(local) == len(remote):
|
|
120
|
+
member = node.item()
|
|
121
|
+
changes = []
|
|
122
|
+
for index, (mine, theirs) in enumerate(zip(local, remote)):
|
|
123
|
+
changes.extend(_field_changes(mine, theirs, member, f"{path}[{index}]"))
|
|
124
|
+
return changes
|
|
125
|
+
|
|
126
|
+
if _differs(local, remote):
|
|
127
|
+
return [_changed(path)]
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _absent_locally(key: str, value: Any, node: SchemaNode, path: str) -> FieldChange:
|
|
132
|
+
"""A key the published config has and the local files do not."""
|
|
133
|
+
return FieldChange(path, value, True, node.is_default(key, value))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _changed(path: str) -> FieldChange:
|
|
137
|
+
"""A difference that is the operator's own: an edited or dropped value."""
|
|
138
|
+
return FieldChange(path, None, False, False)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _compact(value: Any) -> str:
|
|
142
|
+
try:
|
|
143
|
+
return json.dumps(value, sort_keys=True)
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
return repr(value)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _join(parts: list[str]) -> str:
|
|
149
|
+
if len(parts) <= MAX_LISTED_KEYS:
|
|
150
|
+
return ", ".join(parts)
|
|
151
|
+
listed = ", ".join(parts[:MAX_LISTED_KEYS])
|
|
152
|
+
return f"{listed}, and {len(parts) - MAX_LISTED_KEYS} more"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _rows(prefix: str, scope: str, fields: list[FieldChange]) -> list[dict]:
|
|
156
|
+
"""Up to two rows for one item: the operator's changes, and the server's.
|
|
157
|
+
|
|
158
|
+
Split rather than merged so an item that has both -- a real edit plus a
|
|
159
|
+
materialized default -- shows the edit on its own line and in the count,
|
|
160
|
+
with the default visible but not inflating it.
|
|
161
|
+
"""
|
|
162
|
+
mine = [field for field in fields if not field.server_default]
|
|
163
|
+
theirs = [field for field in fields if field.server_default]
|
|
164
|
+
rows = []
|
|
165
|
+
if mine:
|
|
166
|
+
rows.append(
|
|
167
|
+
{
|
|
168
|
+
"type": f"{prefix}_modified",
|
|
169
|
+
"scope": scope,
|
|
170
|
+
"detail": _join([field.path for field in mine]),
|
|
171
|
+
"fields": len(mine),
|
|
172
|
+
"server_default": False,
|
|
173
|
+
# Whether naming this row correctly depended on knowing the
|
|
174
|
+
# schema: with the schema unavailable, some of these keys may
|
|
175
|
+
# be server defaults reported as the operator's changes.
|
|
176
|
+
"needs_schema": any(field.only_on_server for field in mine),
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
if theirs:
|
|
180
|
+
rows.append(
|
|
181
|
+
{
|
|
182
|
+
"type": f"{prefix}_server_default",
|
|
183
|
+
"scope": scope,
|
|
184
|
+
"detail": _join(
|
|
185
|
+
[f"{field.path} = {_compact(field.value)}" for field in theirs]
|
|
186
|
+
),
|
|
187
|
+
"fields": len(theirs),
|
|
188
|
+
"server_default": True,
|
|
189
|
+
"needs_schema": False,
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
return rows
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _diff_configs(local: dict, remote: dict, root: SchemaNode) -> list[dict]:
|
|
35
196
|
"""Changes that turn `remote` into `local`, one row per named item or key."""
|
|
36
197
|
changes: list[dict] = []
|
|
37
198
|
|
|
38
199
|
for key, label in COLLECTIONS.items():
|
|
200
|
+
member = root.child(key).item()
|
|
39
201
|
local_items, remote_items = _by_name(local.get(key)), _by_name(remote.get(key))
|
|
40
202
|
for name in sorted(set(local_items) | set(remote_items)):
|
|
41
203
|
if name not in remote_items:
|
|
42
|
-
changes.append(
|
|
204
|
+
changes.append(_simple(f"{label}_added", name, "only in local files"))
|
|
43
205
|
elif name not in local_items:
|
|
44
|
-
changes.append(
|
|
45
|
-
|
|
46
|
-
|
|
206
|
+
changes.append(
|
|
207
|
+
_simple(f"{label}_removed", name, "only in the published version")
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
changes.extend(
|
|
211
|
+
_rows(
|
|
212
|
+
label,
|
|
213
|
+
name,
|
|
214
|
+
_field_changes(local_items[name], remote_items[name], member),
|
|
215
|
+
)
|
|
216
|
+
)
|
|
47
217
|
|
|
48
218
|
scalar_keys = (set(local) | set(remote)) - set(COLLECTIONS)
|
|
49
219
|
for key in sorted(scalar_keys):
|
|
50
|
-
if
|
|
51
|
-
|
|
220
|
+
if key not in local:
|
|
221
|
+
fields = [_absent_locally(key, remote[key], root, key)]
|
|
222
|
+
elif key not in remote:
|
|
223
|
+
fields = [_changed(key)]
|
|
224
|
+
else:
|
|
225
|
+
fields = _field_changes(local[key], remote[key], root.child(key), key)
|
|
226
|
+
changes.extend(_rows(key, key, fields))
|
|
52
227
|
|
|
53
228
|
return changes
|
|
54
229
|
|
|
55
230
|
|
|
231
|
+
def _simple(change_type: str, scope: str, detail: str) -> dict:
|
|
232
|
+
return {
|
|
233
|
+
"type": change_type,
|
|
234
|
+
"scope": scope,
|
|
235
|
+
"detail": detail,
|
|
236
|
+
"fields": 1,
|
|
237
|
+
"server_default": False,
|
|
238
|
+
"needs_schema": False,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
56
242
|
def _local_item_count(config: dict) -> int:
|
|
57
243
|
collections = sum(len(config.get(key) or []) for key in COLLECTIONS)
|
|
58
244
|
scalars = sum(1 for key, value in config.items() if key not in COLLECTIONS and value)
|
|
59
245
|
return collections + scalars
|
|
60
246
|
|
|
61
247
|
|
|
62
|
-
def
|
|
63
|
-
|
|
248
|
+
def _mark(change_type: str) -> str:
|
|
249
|
+
if change_type.endswith("_added"):
|
|
250
|
+
return f"[green]+ {change_type}[/green]"
|
|
251
|
+
if change_type.endswith("_removed"):
|
|
252
|
+
return f"[red]- {change_type}[/red]"
|
|
253
|
+
if change_type.endswith("_server_default"):
|
|
254
|
+
return f"[cyan]= {change_type}[/cyan]"
|
|
255
|
+
return f"[yellow]~ {change_type}[/yellow]"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _table(changes: list[dict]) -> Table:
|
|
64
259
|
table = Table(show_header=True, header_style="bold")
|
|
65
|
-
table.add_column("Change", width=
|
|
260
|
+
table.add_column("Change", width=26)
|
|
66
261
|
table.add_column("Item")
|
|
67
|
-
|
|
262
|
+
# fold: a Detail cell now carries dotted key paths, and a path is one word
|
|
263
|
+
# to a word-wrapper. Ellipsizing it would drop the very end -- the key
|
|
264
|
+
# name -- which is the part this column exists to show.
|
|
265
|
+
table.add_column("Detail", overflow="fold")
|
|
68
266
|
for change in changes:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
267
|
+
table.add_row(_mark(change["type"]), change["scope"], change["detail"])
|
|
268
|
+
return table
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _render(changes: list[dict], revision: int) -> None:
|
|
272
|
+
mine = [change for change in changes if not change["server_default"]]
|
|
273
|
+
defaults = [change for change in changes if change["server_default"]]
|
|
274
|
+
|
|
275
|
+
if mine:
|
|
276
|
+
console.print(
|
|
277
|
+
f"\n[bold]{len(mine)} change{'' if len(mine) == 1 else 's'} detected[/bold]\n"
|
|
278
|
+
)
|
|
279
|
+
console.print(_table(mine))
|
|
280
|
+
else:
|
|
281
|
+
console.print(
|
|
282
|
+
f"[green]No changes of yours. Local files match published "
|
|
283
|
+
f"v{revision}.[/green]"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if defaults:
|
|
287
|
+
total = sum(change["fields"] for change in defaults)
|
|
288
|
+
console.print(
|
|
289
|
+
f"\n[bold]{total} field{'' if total == 1 else 's'} the server filled "
|
|
290
|
+
"in from a schema default[/bold], absent from your files: written on "
|
|
291
|
+
"every draft save, not changes you made\n"
|
|
292
|
+
)
|
|
293
|
+
console.print(_table(defaults))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _config_surface(client) -> dict:
|
|
297
|
+
"""The `/api/config-surface` catalog, or `{}` if it cannot be had.
|
|
298
|
+
|
|
299
|
+
Deliberately swallows everything: this command must keep working against a
|
|
300
|
+
backend with no `/api/config-surface`, one that answers it with something
|
|
301
|
+
unexpected, or none at all. Fetched exactly once per run and shared by both
|
|
302
|
+
consumers that need it -- the JSON Schema (`schema_root`) and the plugin
|
|
303
|
+
embedded-field list (`plugin_embedded_fields`) -- so a diff against an
|
|
304
|
+
unreachable API still costs one GET, not two.
|
|
305
|
+
"""
|
|
306
|
+
try:
|
|
307
|
+
surface = client.config_surface()
|
|
308
|
+
return surface if isinstance(surface, dict) else {}
|
|
309
|
+
except Exception:
|
|
310
|
+
return {}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _strip_plugin_embedded_fields(
|
|
314
|
+
remote_flow: dict, local_flow: dict | None, fields: frozenset[str]
|
|
315
|
+
) -> dict:
|
|
316
|
+
"""A deep copy of `remote_flow` with publish-materialized plugin
|
|
317
|
+
coordinates dropped from any `action == "plugin"` step whose `local_flow`
|
|
318
|
+
counterpart (matched by step id) does not declare them (#1366).
|
|
319
|
+
|
|
320
|
+
Mirrors `publish_service._normalize_plugin_step_fields` (#690), the
|
|
321
|
+
backend's own fix for the same bug shape one level up (draft vs active
|
|
322
|
+
version instead of local files vs published version). Scoped strictly to
|
|
323
|
+
`step["plugin"]` on `action == "plugin"` steps and matched by step id, so a
|
|
324
|
+
real drift -- both sides declaring the field, with different values --
|
|
325
|
+
still surfaces as a normal change.
|
|
326
|
+
"""
|
|
327
|
+
if not fields:
|
|
328
|
+
return remote_flow
|
|
329
|
+
local_steps_by_id = {
|
|
330
|
+
s["id"]: s
|
|
331
|
+
for s in (local_flow or {}).get("steps", []) or []
|
|
332
|
+
if isinstance(s, dict) and s.get("id")
|
|
333
|
+
}
|
|
334
|
+
normalized = copy.deepcopy(remote_flow)
|
|
335
|
+
for step in normalized.get("steps", []) or []:
|
|
336
|
+
if step.get("action") != "plugin" or not step.get("id"):
|
|
337
|
+
continue
|
|
338
|
+
plugin_block = step.get("plugin")
|
|
339
|
+
if not isinstance(plugin_block, dict):
|
|
340
|
+
continue
|
|
341
|
+
local_step = local_steps_by_id.get(step["id"]) or {}
|
|
342
|
+
local_plugin_block = local_step.get("plugin")
|
|
343
|
+
if not isinstance(local_plugin_block, dict):
|
|
344
|
+
local_plugin_block = {}
|
|
345
|
+
for field_name in fields:
|
|
346
|
+
if field_name in plugin_block and field_name not in local_plugin_block:
|
|
347
|
+
del plugin_block[field_name]
|
|
348
|
+
return normalized
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _normalize_remote_flows(
|
|
352
|
+
remote_flows: list, local_flows: list, fields: frozenset[str]
|
|
353
|
+
) -> list:
|
|
354
|
+
local_by_name = {f["name"]: f for f in local_flows or [] if isinstance(f, dict)}
|
|
355
|
+
return [
|
|
356
|
+
_strip_plugin_embedded_fields(f, local_by_name.get(f.get("name")), fields)
|
|
357
|
+
for f in remote_flows or []
|
|
358
|
+
if isinstance(f, dict)
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _warn_defaults_unknown(changes: list[dict]) -> None:
|
|
363
|
+
"""Say why a change may not be one, but only when the doubt is real.
|
|
364
|
+
|
|
365
|
+
Fires only if the schema was unavailable AND something differs solely by
|
|
366
|
+
being absent locally: that is the shape a materialized default has. Any
|
|
367
|
+
other diff is unaffected by the missing schema and must not drag a warning
|
|
368
|
+
an operator would learn to skip.
|
|
369
|
+
"""
|
|
370
|
+
if not any(change["needs_schema"] for change in changes):
|
|
371
|
+
return
|
|
372
|
+
console.print(
|
|
373
|
+
"\n[yellow]Some keys below exist only in the published version. This "
|
|
374
|
+
"API does not serve the config schema (GET /api/config-surface), so "
|
|
375
|
+
"the ones the server materialized from a schema default cannot be told "
|
|
376
|
+
"apart from your own edits. Upgrade the API to see them separated."
|
|
377
|
+
"[/yellow]"
|
|
378
|
+
)
|
|
78
379
|
|
|
79
380
|
|
|
80
381
|
@click.command()
|
|
@@ -92,6 +393,10 @@ def diff(path: str | None, revision: int | None):
|
|
|
92
393
|
Read-only: nothing is uploaded. To see what a publish would change instead,
|
|
93
394
|
use `plugsync preview`, which diffs the DRAFT against the active version.
|
|
94
395
|
|
|
396
|
+
Each modified item names the keys that differ. Keys the server wrote from a
|
|
397
|
+
schema default (`on_target_deleted`, `pipelines`, ...) are listed
|
|
398
|
+
separately and excluded from the change count: they are not your edits.
|
|
399
|
+
|
|
95
400
|
Examples:
|
|
96
401
|
plugsync diff
|
|
97
402
|
plugsync diff ./hubspot-juve
|
|
@@ -115,8 +420,20 @@ def diff(path: str | None, revision: int | None):
|
|
|
115
420
|
fail(f"Version {revision} carries no readable config.")
|
|
116
421
|
|
|
117
422
|
console.print(f"Comparing local files against published v{revision}...")
|
|
118
|
-
|
|
423
|
+
surface = _config_surface(client)
|
|
424
|
+
root = schema_root(surface.get("json_schema"))
|
|
425
|
+
fields = plugin_embedded_fields(surface)
|
|
426
|
+
if isinstance(remote_config.get("flows"), list):
|
|
427
|
+
remote_config = {
|
|
428
|
+
**remote_config,
|
|
429
|
+
"flows": _normalize_remote_flows(
|
|
430
|
+
remote_config["flows"], local_config.get("flows"), fields
|
|
431
|
+
),
|
|
432
|
+
}
|
|
433
|
+
changes = _diff_configs(local_config, remote_config, root)
|
|
119
434
|
if not changes:
|
|
120
435
|
console.print(f"[green]No changes. Local files match published v{revision}.[/green]")
|
|
121
436
|
return
|
|
122
|
-
_render(changes)
|
|
437
|
+
_render(changes, revision)
|
|
438
|
+
if not root.known:
|
|
439
|
+
_warn_defaults_unknown(changes)
|
plugsync_cli/commands/plugin.py
CHANGED
|
@@ -232,7 +232,29 @@ def plugin_push(path: str | None):
|
|
|
232
232
|
result = client.plugin_push(plugin_id, bundle_bytes, source_code)
|
|
233
233
|
except httpx.HTTPStatusError as exc:
|
|
234
234
|
_handle_plugin_api_error(exc)
|
|
235
|
-
|
|
235
|
+
|
|
236
|
+
# `result["status"]` is the plugin's PROMOTION status (draft/dev/staging/live/...),
|
|
237
|
+
# not the outcome of this push -- push never writes Plugin.status itself
|
|
238
|
+
# (backend/app/api/plugins.py's push_plugin only stores the bundle and enqueues a
|
|
239
|
+
# deploy_plugin_dev outbox row). Reporting it bare as "Pushed - status: live" reads
|
|
240
|
+
# as "this push went live", which is false whenever the plugin was already promoted
|
|
241
|
+
# before this push (issue #1364/DX-58). Spell out what actually happened instead.
|
|
242
|
+
live_str = result.get("live_version") or "none"
|
|
243
|
+
staging_str = result.get("staging_version") or "none"
|
|
244
|
+
|
|
245
|
+
# Kept as short, separate print() calls on purpose: Rich wraps a single long
|
|
246
|
+
# logical line at the terminal width (80 cols when not a tty), which would
|
|
247
|
+
# otherwise fold a real plugin_id (a 36-char UUID) mid-command and make the
|
|
248
|
+
# printed `plugsync plugin status <id>` hint uncopy-pasteable.
|
|
249
|
+
console.print("[green]Bundle uploaded, dev deploy enqueued.[/green]")
|
|
250
|
+
console.print(
|
|
251
|
+
f"Promotion status: {result.get('status', '?')} "
|
|
252
|
+
f"(live_version {live_str}, staging_version {staging_str})."
|
|
253
|
+
)
|
|
254
|
+
console.print("Run `plugsync plugin promote` to ship it.")
|
|
255
|
+
console.print("Dev deploy runs asynchronously; it may take a few seconds.")
|
|
256
|
+
console.print(f"Check outcome: `plugsync plugin status {plugin_id}`")
|
|
257
|
+
console.print(f"or `plugsync plugin logs {plugin_id}`.")
|
|
236
258
|
|
|
237
259
|
|
|
238
260
|
# ---------------------------------------------------------------------------
|
|
@@ -331,10 +353,15 @@ def plugin_status_cmd(plugin_id: str):
|
|
|
331
353
|
|
|
332
354
|
info = client.plugin_status(plugin_id)
|
|
333
355
|
console.print(f"[bold]{info.get('name', plugin_id)}[/bold]")
|
|
334
|
-
console.print(f" id:
|
|
335
|
-
console.print(f" status:
|
|
356
|
+
console.print(f" id: {info.get('id', plugin_id)}")
|
|
357
|
+
console.print(f" status: {info.get('status', '?')}")
|
|
358
|
+
console.print(f" staging_version: {info.get('staging_version') or 'none'}")
|
|
359
|
+
console.print(f" live_version: {info.get('live_version') or 'none'}")
|
|
360
|
+
console.print(f" function_arn: {info.get('function_arn') or 'none'}")
|
|
361
|
+
if info.get("error_message"):
|
|
362
|
+
console.print(f" error_message: {info['error_message']}")
|
|
336
363
|
if info.get("bundle_hash"):
|
|
337
|
-
console.print(f" hash:
|
|
364
|
+
console.print(f" hash: {info['bundle_hash'][:12]}...")
|
|
338
365
|
|
|
339
366
|
|
|
340
367
|
# ---------------------------------------------------------------------------
|
|
@@ -422,13 +449,49 @@ def plugin_metrics_cmd(plugin_id: str, window: str):
|
|
|
422
449
|
"--payload",
|
|
423
450
|
"-p",
|
|
424
451
|
default="{}",
|
|
425
|
-
help=
|
|
452
|
+
help=(
|
|
453
|
+
'JSON event BODY to send (default: "{}"). This is only the '
|
|
454
|
+
"`payload` key of the PluginPayload your handler receives -- it "
|
|
455
|
+
"lands at event.payload, NOT the whole envelope. Use --config and "
|
|
456
|
+
"--auth for the other keys a plugin can read."
|
|
457
|
+
),
|
|
426
458
|
)
|
|
427
|
-
|
|
459
|
+
@click.option(
|
|
460
|
+
"--config",
|
|
461
|
+
"-c",
|
|
462
|
+
default=None,
|
|
463
|
+
help=(
|
|
464
|
+
"JSON object to send as event.config, mirroring a step's authored "
|
|
465
|
+
"`plugin.config` (default: {})."
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
@click.option(
|
|
469
|
+
"--auth",
|
|
470
|
+
"-a",
|
|
471
|
+
"auth",
|
|
472
|
+
default=None,
|
|
473
|
+
help=(
|
|
474
|
+
"Name of an event source (NOT a secret) whose outbound auth the "
|
|
475
|
+
"server resolves into headers, exactly as a real flow resolves it: "
|
|
476
|
+
"the source's own credential, auth_type and auth_header. The "
|
|
477
|
+
"headers land at event.auth[<source-name>], the key a real `auth` "
|
|
478
|
+
"capability grant uses, and the plugin must declare that grant "
|
|
479
|
+
"(`auth:<source-name>`) or the invoke is refused. Two more forms: "
|
|
480
|
+
"`<credential-name>`, used when no source of your org carries that "
|
|
481
|
+
"name, resolves a stored credential with the default header for "
|
|
482
|
+
"its type; `<source-name>=<credential-name>` pins that credential "
|
|
483
|
+
"onto the named source's header shape."
|
|
484
|
+
),
|
|
485
|
+
)
|
|
486
|
+
def plugin_invoke_cmd(plugin_id: str, payload: str, config: str | None, auth: str | None):
|
|
428
487
|
"""Invoke a plugin with a test payload.
|
|
429
488
|
|
|
430
489
|
Example:
|
|
431
490
|
plugsync plugin invoke <id> --payload '{"key": "value"}'
|
|
491
|
+
plugsync plugin invoke <id> --payload '{"key": "value"}' \\
|
|
492
|
+
--config '{"doc_type": "invoice"}' --auth kvk
|
|
493
|
+
plugsync plugin invoke <id> --payload '{"key": "value"}' \\
|
|
494
|
+
--auth kvk=erp_api_key
|
|
432
495
|
"""
|
|
433
496
|
try:
|
|
434
497
|
payload_dict = json.loads(payload)
|
|
@@ -436,13 +499,37 @@ def plugin_invoke_cmd(plugin_id: str, payload: str):
|
|
|
436
499
|
console.print(f"[red]Invalid JSON payload: {e}[/red]")
|
|
437
500
|
raise SystemExit(1)
|
|
438
501
|
|
|
502
|
+
config_dict: dict = {}
|
|
503
|
+
if config is not None:
|
|
504
|
+
try:
|
|
505
|
+
config_dict = json.loads(config)
|
|
506
|
+
except json.JSONDecodeError as e:
|
|
507
|
+
console.print(f"[red]Invalid JSON config: {e}[/red]")
|
|
508
|
+
raise SystemExit(1)
|
|
509
|
+
|
|
510
|
+
# `--auth <name>` (a source name, falling back server-side to a credential
|
|
511
|
+
# name when the org has no source called that) or `--auth <source>=<cred>`
|
|
512
|
+
# (an explicit credential pinned onto that source's header shape). Either
|
|
513
|
+
# way the part before the "=" -- or the whole value -- is the event.auth
|
|
514
|
+
# key AND the `auth` capability grant the server requires.
|
|
515
|
+
auth_credential = auth
|
|
516
|
+
auth_key: str | None = None
|
|
517
|
+
if auth is not None and "=" in auth:
|
|
518
|
+
auth_key, auth_credential = auth.split("=", 1)
|
|
519
|
+
if not auth_key or not auth_credential:
|
|
520
|
+
console.print(
|
|
521
|
+
"[red]Invalid --auth: expected <source-name>=<credential-name>, "
|
|
522
|
+
f"got {auth!r}[/red]"
|
|
523
|
+
)
|
|
524
|
+
raise SystemExit(1)
|
|
525
|
+
|
|
439
526
|
try:
|
|
440
527
|
client = PlugSyncClient()
|
|
441
528
|
except RuntimeError as e:
|
|
442
529
|
console.print(f"[red]{e}[/red]")
|
|
443
530
|
raise SystemExit(1)
|
|
444
531
|
|
|
445
|
-
result = client.plugin_invoke(plugin_id, payload_dict)
|
|
532
|
+
result = client.plugin_invoke(plugin_id, payload_dict, config_dict, auth_credential, auth_key)
|
|
446
533
|
console.print(json.dumps(result, indent=2))
|
|
447
534
|
|
|
448
535
|
|
plugsync_cli/commands/push.py
CHANGED
|
@@ -39,7 +39,26 @@ console = Console()
|
|
|
39
39
|
default=False,
|
|
40
40
|
help="Overwrite the remote draft even if it changed since you pulled",
|
|
41
41
|
)
|
|
42
|
-
|
|
42
|
+
@click.option(
|
|
43
|
+
"--republish",
|
|
44
|
+
is_flag=True,
|
|
45
|
+
default=False,
|
|
46
|
+
help=(
|
|
47
|
+
"With --publish: publish a new version even if the draft already "
|
|
48
|
+
"matches the active one, to refresh values a publish resolves (e.g. "
|
|
49
|
+
"a plugin promoted to a new live version, a HubSpot schema drifted "
|
|
50
|
+
"since the last publish). Unrelated to --force, which is about a "
|
|
51
|
+
"concurrent DRAFT edit, not the active version."
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
def push(
|
|
55
|
+
path: str | None,
|
|
56
|
+
message: str | None,
|
|
57
|
+
do_publish: bool,
|
|
58
|
+
no_publish: bool,
|
|
59
|
+
force: bool,
|
|
60
|
+
republish: bool,
|
|
61
|
+
):
|
|
43
62
|
"""Upload a local connector directory to its draft.
|
|
44
63
|
|
|
45
64
|
Nothing is published unless you pass --publish. Without it the config lands
|
|
@@ -50,9 +69,12 @@ def push(path: str | None, message: str | None, do_publish: bool, no_publish: bo
|
|
|
50
69
|
plugsync push ./hubspot-juve # push a specific directory
|
|
51
70
|
plugsync push --publish -m "Added phone"
|
|
52
71
|
plugsync push --force # discard a concurrent draft edit
|
|
72
|
+
plugsync push --publish --republish # re-publish an identical draft
|
|
53
73
|
"""
|
|
54
74
|
if do_publish and no_publish:
|
|
55
75
|
fail("--publish and --no-publish contradict each other; pick one.")
|
|
76
|
+
if republish and not do_publish:
|
|
77
|
+
fail("--republish only makes sense together with --publish.")
|
|
56
78
|
if no_publish:
|
|
57
79
|
console.print(
|
|
58
80
|
"[yellow]--no-publish is deprecated: push is draft-only by default. "
|
|
@@ -85,10 +107,10 @@ def push(path: str | None, message: str | None, do_publish: bool, no_publish: bo
|
|
|
85
107
|
)
|
|
86
108
|
return
|
|
87
109
|
|
|
88
|
-
_publish(client, context.connector_id, message)
|
|
110
|
+
_publish(client, context.connector_id, message, republish=republish)
|
|
89
111
|
|
|
90
112
|
|
|
91
|
-
def _publish(client, connector_id: str, message: str | None) -> None:
|
|
113
|
+
def _publish(client, connector_id: str, message: str | None, republish: bool = False) -> None:
|
|
92
114
|
console.print("Running preflight before publishing...")
|
|
93
115
|
error_count = render_preflight(console, client.preflight(connector_id))
|
|
94
116
|
if error_count:
|
|
@@ -97,12 +119,19 @@ def _publish(client, connector_id: str, message: str | None) -> None:
|
|
|
97
119
|
"published. Fix the errors and push again."
|
|
98
120
|
)
|
|
99
121
|
|
|
100
|
-
result = client.publish(connector_id, change_summary=message)
|
|
122
|
+
result = client.publish(connector_id, change_summary=message, force=republish)
|
|
101
123
|
if result.get("no_changes"):
|
|
102
124
|
console.print(
|
|
103
125
|
f"[yellow]Nothing to publish: v{result.get('version')} already "
|
|
104
126
|
"matches the draft.[/yellow]"
|
|
105
127
|
)
|
|
128
|
+
console.print(
|
|
129
|
+
"The draft is identical to the active version. If the values a "
|
|
130
|
+
"publish RESOLVES have since changed - a plugin promoted to a new "
|
|
131
|
+
"live version, a credential rebind, HubSpot schema provisioning "
|
|
132
|
+
"that needs to re-run - re-run with [bold]--publish --republish[/bold] "
|
|
133
|
+
"to force a new version anyway."
|
|
134
|
+
)
|
|
106
135
|
return
|
|
107
136
|
console.print(f"[green]Published v{result.get('version')}[/green]")
|
|
108
137
|
warnings = result.get("warnings") or []
|
plugsync_cli/context.py
CHANGED
|
@@ -15,6 +15,7 @@ from plugsync_cli.client import DraftConflict, PlugSyncClient
|
|
|
15
15
|
from plugsync_cli.serializer import (
|
|
16
16
|
CONFIG_FILE_NAME,
|
|
17
17
|
ConfigDirectoryError,
|
|
18
|
+
Normalization,
|
|
18
19
|
canonical_config,
|
|
19
20
|
flows_sharing_a_trigger,
|
|
20
21
|
read_config_directory,
|
|
@@ -37,6 +38,14 @@ def fail(message: str) -> NoReturn:
|
|
|
37
38
|
raise SystemExit(1)
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
def warn_normalized(note: Normalization) -> None:
|
|
42
|
+
"""Say that a local value did not reach the API as it was written (#1254).
|
|
43
|
+
|
|
44
|
+
soft_wrap for the same reason `fail` uses it: the note carries a file path.
|
|
45
|
+
"""
|
|
46
|
+
console.print(f"[yellow]{note}[/yellow]", soft_wrap=True)
|
|
47
|
+
|
|
48
|
+
|
|
40
49
|
def build_client() -> PlugSyncClient:
|
|
41
50
|
"""A configured client, or a clean exit explaining what is missing."""
|
|
42
51
|
try:
|
|
@@ -59,7 +68,7 @@ class ConnectorContext:
|
|
|
59
68
|
|
|
60
69
|
def config(self) -> dict:
|
|
61
70
|
try:
|
|
62
|
-
return read_config_directory(self.directory)
|
|
71
|
+
return read_config_directory(self.directory, report=warn_normalized)
|
|
63
72
|
except ConfigDirectoryError as exc:
|
|
64
73
|
fail(str(exc))
|
|
65
74
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Walk the connector-config JSON Schema to answer "is this value a default?".
|
|
2
|
+
|
|
3
|
+
Why this exists (issue #1259): the backend validates every draft write and
|
|
4
|
+
stores the canonicalized result, which materializes the schema defaults the
|
|
5
|
+
input omitted -- `on_target_deleted: "skip"`, `pipelines: []`, `settings: {}`.
|
|
6
|
+
A hand-written connector directory does not carry them, so `plugsync diff`
|
|
7
|
+
reported six modified entities to an operator who had changed nothing.
|
|
8
|
+
|
|
9
|
+
Telling those apart needs the defaults, and the defaults have to come FROM the
|
|
10
|
+
schema: this package ships on PyPI on its own, cannot import `app.*`, and a
|
|
11
|
+
table of defaults copied into the client would be a second source of truth free
|
|
12
|
+
to drift from the models the server actually validates with. So the schema is
|
|
13
|
+
read at runtime off `GET /api/config-surface`, whose `json_schema` is
|
|
14
|
+
`ConnectorConfigV3.model_json_schema()`, and this module navigates it.
|
|
15
|
+
|
|
16
|
+
Everything here is a null object: an unknown position answers "no properties,
|
|
17
|
+
no default" rather than `None`, and `schema_root(None)` is a perfectly usable
|
|
18
|
+
root that simply knows no defaults. That is what makes the degradation free --
|
|
19
|
+
against a backend with no `/api/config-surface`, or an unreachable one, the
|
|
20
|
+
walker keeps working and every difference is reported as the caller's own,
|
|
21
|
+
which is exactly the pre-#1259 behaviour.
|
|
22
|
+
"""
|
|
23
|
+
import json
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
# Distinct from `None`: `None` is itself a declared default all over this
|
|
27
|
+
# schema (every `X | None = None` field), so "no default declared" needs a
|
|
28
|
+
# value that cannot collide with one.
|
|
29
|
+
MISSING = object()
|
|
30
|
+
|
|
31
|
+
# Guards a `$ref` cycle. The schema is a tree today; a self-referential model
|
|
32
|
+
# (a pipeline holding pipelines) would otherwise spin here forever.
|
|
33
|
+
_MAX_REF_HOPS = 32
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _same(left: Any, right: Any) -> bool:
|
|
37
|
+
"""Whether two JSON values are the same document, key order aside."""
|
|
38
|
+
try:
|
|
39
|
+
return json.dumps(left, sort_keys=True) == json.dumps(right, sort_keys=True)
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SchemaNode:
|
|
45
|
+
"""One position in the config, and what the schema declares about it.
|
|
46
|
+
|
|
47
|
+
Navigation mirrors the shape of the config document, not of the schema:
|
|
48
|
+
`child(key)` for a mapping key (a model field, or a value of a
|
|
49
|
+
`dict[str, Model]`), `item()` for a member of a list. Both always return a
|
|
50
|
+
node, empty when the schema says nothing about that position.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
__slots__ = ("_schema", "_defs")
|
|
54
|
+
|
|
55
|
+
def __init__(self, schema: dict | None, defs: dict):
|
|
56
|
+
self._schema = schema if isinstance(schema, dict) else {}
|
|
57
|
+
self._defs = defs
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def known(self) -> bool:
|
|
61
|
+
"""Whether the schema actually describes this position.
|
|
62
|
+
|
|
63
|
+
False for the root built from a schema that could not be fetched, which
|
|
64
|
+
is how a caller tells "no defaults declared here" from "no schema at
|
|
65
|
+
all" and can say so instead of silently reporting more changes.
|
|
66
|
+
"""
|
|
67
|
+
return bool(self._schema)
|
|
68
|
+
|
|
69
|
+
# -- navigation ---------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def child(self, key: str) -> "SchemaNode":
|
|
72
|
+
"""The node for a mapping key: a declared property, or a dict value."""
|
|
73
|
+
properties = self._schema.get("properties")
|
|
74
|
+
if isinstance(properties, dict) and key in properties:
|
|
75
|
+
return self._node(properties[key])
|
|
76
|
+
# `dict[str, EntitySchemaSpec]`: the key is free-form ("hubspot"), the
|
|
77
|
+
# value's shape is declared once under additionalProperties.
|
|
78
|
+
extra = self._schema.get("additionalProperties")
|
|
79
|
+
if isinstance(extra, dict):
|
|
80
|
+
return self._node(extra)
|
|
81
|
+
return SchemaNode({}, self._defs)
|
|
82
|
+
|
|
83
|
+
def item(self) -> "SchemaNode":
|
|
84
|
+
"""The node for a member of this list."""
|
|
85
|
+
return self._node(self._schema.get("items"))
|
|
86
|
+
|
|
87
|
+
# -- defaults -----------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def default_for(self, key: str) -> Any:
|
|
90
|
+
"""The default declared for `key` here, or `MISSING`."""
|
|
91
|
+
properties = self._schema.get("properties")
|
|
92
|
+
if not isinstance(properties, dict):
|
|
93
|
+
return MISSING
|
|
94
|
+
declared = properties.get(key)
|
|
95
|
+
if not isinstance(declared, dict) or "default" not in declared:
|
|
96
|
+
return MISSING
|
|
97
|
+
return declared["default"]
|
|
98
|
+
|
|
99
|
+
def is_default(self, key: str, value: Any) -> bool:
|
|
100
|
+
"""Whether `value` is exactly what the schema defaults `key` to."""
|
|
101
|
+
default = self.default_for(key)
|
|
102
|
+
return default is not MISSING and _same(default, value)
|
|
103
|
+
|
|
104
|
+
# -- internals ----------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def _node(self, schema: Any) -> "SchemaNode":
|
|
107
|
+
return SchemaNode(self._resolve(schema), self._defs)
|
|
108
|
+
|
|
109
|
+
def _resolve(self, schema: Any) -> dict:
|
|
110
|
+
"""Follow `$ref` and unwrap `Optional[T]` down to a schema with fields.
|
|
111
|
+
|
|
112
|
+
Pydantic writes a model-typed field as `{"$ref": "#/$defs/EntityV3"}`
|
|
113
|
+
and an optional one as `{"anyOf": [{"$ref": ...}, {"type": "null"}],
|
|
114
|
+
"default": null}`, so neither carries `properties` where the walker
|
|
115
|
+
needs them. A union with more than one non-null branch is given up on:
|
|
116
|
+
which branch a concrete value belongs to is a validation question, and
|
|
117
|
+
guessing it would attribute one member's defaults to another's values.
|
|
118
|
+
"""
|
|
119
|
+
for _ in range(_MAX_REF_HOPS):
|
|
120
|
+
if not isinstance(schema, dict):
|
|
121
|
+
return {}
|
|
122
|
+
ref = schema.get("$ref")
|
|
123
|
+
if isinstance(ref, str):
|
|
124
|
+
schema = self._deref(ref)
|
|
125
|
+
continue
|
|
126
|
+
branches = schema.get("anyOf") or schema.get("oneOf")
|
|
127
|
+
if isinstance(branches, list):
|
|
128
|
+
candidates = [
|
|
129
|
+
branch
|
|
130
|
+
for branch in branches
|
|
131
|
+
if isinstance(branch, dict) and branch.get("type") != "null"
|
|
132
|
+
]
|
|
133
|
+
if len(candidates) != 1:
|
|
134
|
+
return {}
|
|
135
|
+
schema = candidates[0]
|
|
136
|
+
continue
|
|
137
|
+
return schema
|
|
138
|
+
return {}
|
|
139
|
+
|
|
140
|
+
def _deref(self, ref: str) -> dict:
|
|
141
|
+
prefix = "#/$defs/"
|
|
142
|
+
if not ref.startswith(prefix):
|
|
143
|
+
return {}
|
|
144
|
+
return self._defs.get(ref[len(prefix):], {})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def schema_root(json_schema: dict | None) -> SchemaNode:
|
|
148
|
+
"""The root node of a connector-config JSON Schema.
|
|
149
|
+
|
|
150
|
+
Anything other than a schema object -- `None`, a stub left by an endpoint
|
|
151
|
+
that answered something unexpected -- gives an empty root, which knows no
|
|
152
|
+
defaults and reports `known == False`.
|
|
153
|
+
"""
|
|
154
|
+
schema = json_schema if isinstance(json_schema, dict) else {}
|
|
155
|
+
defs = schema.get("$defs")
|
|
156
|
+
return SchemaNode(schema, defs if isinstance(defs, dict) else {})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def plugin_embedded_fields(config_surface: dict | None) -> frozenset[str]:
|
|
160
|
+
"""Field names a publish materializes into a plugin step's `plugin` block
|
|
161
|
+
(`function_arn`, `version`, `capabilities`) that the authored draft form
|
|
162
|
+
never declares (issue #1366).
|
|
163
|
+
|
|
164
|
+
Read from `GET /api/config-surface`'s `plugin_embedded_fields`, the same
|
|
165
|
+
single source of truth `publish_service.compute_diff` reads server-side
|
|
166
|
+
for the dashboard's draft/version diff (#690) -- never a list copied here,
|
|
167
|
+
for the reason `schema_root` above already gives: this package cannot
|
|
168
|
+
import `app.*`, and a copy is free to drift from what the server actually
|
|
169
|
+
embeds.
|
|
170
|
+
|
|
171
|
+
Same null-object degradation as `schema_root`: an absent/malformed value
|
|
172
|
+
(an older backend with no `plugin_embedded_fields`, or a `config_surface`
|
|
173
|
+
that could not be fetched at all) answers "nothing known", which strips
|
|
174
|
+
nothing -- exactly the pre-#1366 behaviour, so a plugin step's
|
|
175
|
+
server-resolved coordinates go back to reading as the operator's own
|
|
176
|
+
change instead of crashing the command.
|
|
177
|
+
"""
|
|
178
|
+
if not isinstance(config_surface, dict):
|
|
179
|
+
return frozenset()
|
|
180
|
+
fields = config_surface.get("plugin_embedded_fields")
|
|
181
|
+
if not isinstance(fields, list):
|
|
182
|
+
return frozenset()
|
|
183
|
+
return frozenset(f for f in fields if isinstance(f, str))
|
plugsync_cli/serializer.py
CHANGED
|
@@ -39,7 +39,15 @@ That is the only case where the order is observable, so it is the case
|
|
|
39
39
|
`flows_sharing_a_trigger` reports and `pull`/`push` warn about, rather than
|
|
40
40
|
being encoded on disk: an explicit order file would be a second source of
|
|
41
41
|
truth for the flow list, free to drift from the files actually present.
|
|
42
|
+
|
|
43
|
+
The config is a JSON document, so reading is also where a file stops being
|
|
44
|
+
"whatever YAML built" and becomes "something JSON can carry" -- see
|
|
45
|
+
`_ConfigLoader` and `_json_value` (issue #1254).
|
|
42
46
|
"""
|
|
47
|
+
from collections.abc import Callable
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
from datetime import date, datetime, time
|
|
50
|
+
from math import isfinite
|
|
43
51
|
from pathlib import Path
|
|
44
52
|
|
|
45
53
|
import yaml
|
|
@@ -64,6 +72,186 @@ class ConfigDirectoryError(Exception):
|
|
|
64
72
|
"""The local directory is not a readable connector config."""
|
|
65
73
|
|
|
66
74
|
|
|
75
|
+
_TIMESTAMP_TAG = "tag:yaml.org,2002:timestamp"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _ConfigLoader(yaml.SafeLoader):
|
|
79
|
+
"""`SafeLoader` without the implicit timestamp resolver.
|
|
80
|
+
|
|
81
|
+
A connector directory is meant to be edited by hand, and YAML 1.1 resolves
|
|
82
|
+
an unquoted `2026-07-26` to `datetime.date`. The config is a JSON document:
|
|
83
|
+
`push` PUTs it as JSON and `diff` compares two of them with `json.dumps`, so
|
|
84
|
+
that date used to end both commands in a `TypeError` raised from inside
|
|
85
|
+
httpx, naming no file and no key (issue #1254).
|
|
86
|
+
|
|
87
|
+
Dropping the resolver rather than converting the date afterwards is what
|
|
88
|
+
makes the common case lossless. `str(date(2026, 7, 26))` happens to give the
|
|
89
|
+
characters back, but `2026-07-26T10:30:00Z` would come back as
|
|
90
|
+
"2026-07-26 10:30:00+00:00" -- a value nobody typed. Unresolved, the scalar
|
|
91
|
+
stays the text on disk, whatever shape it has.
|
|
92
|
+
|
|
93
|
+
The dumper still quotes such scalars on the way out (`yaml.safe_dump` uses
|
|
94
|
+
the full resolver set), so a file this CLI writes keeps reading as a string
|
|
95
|
+
for every other YAML tool too.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Rebuilt on the subclass only: `yaml_implicit_resolvers` lives on PyYAML's
|
|
100
|
+
# shared `BaseResolver`, and mutating it in place would silently un-quote the
|
|
101
|
+
# dumper too, which is what keeps a file this CLI writes unambiguous.
|
|
102
|
+
_ConfigLoader.yaml_implicit_resolvers = {
|
|
103
|
+
first_char: [(tag, regexp) for tag, regexp in resolvers if tag != _TIMESTAMP_TAG]
|
|
104
|
+
for first_char, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Normalization:
|
|
110
|
+
"""A value the reader had to change to make the config representable as JSON.
|
|
111
|
+
|
|
112
|
+
Only an explicitly tagged value can produce one: with the timestamp resolver
|
|
113
|
+
gone, nothing an operator types by accident is normalized. Reported rather
|
|
114
|
+
than applied in silence -- a config quietly holding a value other than the
|
|
115
|
+
one in the file is how a directory stops being the source of truth.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
source: str
|
|
119
|
+
path: str
|
|
120
|
+
detail: str
|
|
121
|
+
|
|
122
|
+
def __str__(self) -> str:
|
|
123
|
+
return f"{self.source}: {self.path} {self.detail}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# What `json.dumps` writes for the object keys it accepts without complaint.
|
|
127
|
+
# Reproduced rather than deferred to, so a bool key and a date key -- one of
|
|
128
|
+
# which `json.dumps` coerces and the other of which it rejects -- come out of
|
|
129
|
+
# here the same way, both reported.
|
|
130
|
+
_KEY_LITERALS = {True: "true", False: "false", None: "null"}
|
|
131
|
+
|
|
132
|
+
Report = Callable[[Normalization], None]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _ignore(_: Normalization) -> None:
|
|
136
|
+
"""Drop normalization notes: for callers that only want the config."""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _child(path: str, key: str) -> str:
|
|
140
|
+
return f"{path}.{key}" if path else key
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _json_key(key, *, source: str, path: str, report: Report) -> str:
|
|
144
|
+
"""A mapping key as the string a JSON object has to be keyed by."""
|
|
145
|
+
if isinstance(key, str):
|
|
146
|
+
return key
|
|
147
|
+
if isinstance(key, bool) or key is None:
|
|
148
|
+
text = _KEY_LITERALS[key]
|
|
149
|
+
elif isinstance(key, (int, float)):
|
|
150
|
+
text = repr(key)
|
|
151
|
+
elif isinstance(key, (datetime, date, time)):
|
|
152
|
+
text = key.isoformat()
|
|
153
|
+
else:
|
|
154
|
+
raise ConfigDirectoryError(
|
|
155
|
+
f"{source}: the mapping at {path or 'the document root'} is keyed by "
|
|
156
|
+
f"a {type(key).__name__}, which JSON has no key for. Quote the key."
|
|
157
|
+
)
|
|
158
|
+
report(
|
|
159
|
+
Normalization(
|
|
160
|
+
source,
|
|
161
|
+
_child(path, text),
|
|
162
|
+
f"was keyed by {key!r}, stored under the string {text!r}: "
|
|
163
|
+
"JSON objects are keyed by strings. Quote the key to keep it as written.",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
return text
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _json_value(value, *, source: str, path: str, report: Report):
|
|
170
|
+
"""`value` as something JSON can carry, reporting every change on the way.
|
|
171
|
+
|
|
172
|
+
The audit behind the branches (issue #1254, checked against PyYAML rather
|
|
173
|
+
than recalled) is `tests/test_hand_edited_yaml.py`: of everything
|
|
174
|
+
`yaml.SafeLoader` can build, JSON has no timestamp, no bytes, no set, no
|
|
175
|
+
tuple and no non-string key, and cannot write a non-finite float. What has
|
|
176
|
+
a faithful text form is converted and reported; what does not is refused
|
|
177
|
+
here, naming the file and the key, instead of surfacing as a `TypeError`
|
|
178
|
+
from inside httpx.
|
|
179
|
+
"""
|
|
180
|
+
if isinstance(value, dict):
|
|
181
|
+
normalized = {}
|
|
182
|
+
for key, item in value.items():
|
|
183
|
+
name = _json_key(key, source=source, path=path, report=report)
|
|
184
|
+
normalized[name] = _json_value(
|
|
185
|
+
item, source=source, path=_child(path, name), report=report
|
|
186
|
+
)
|
|
187
|
+
return normalized
|
|
188
|
+
|
|
189
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
190
|
+
return _json_sequence(value, source=source, path=path, report=report)
|
|
191
|
+
|
|
192
|
+
if isinstance(value, (datetime, date, time)):
|
|
193
|
+
# Only an explicit !!timestamp reaches this: see `_ConfigLoader`.
|
|
194
|
+
text = value.isoformat()
|
|
195
|
+
report(
|
|
196
|
+
Normalization(
|
|
197
|
+
source,
|
|
198
|
+
path,
|
|
199
|
+
f"is a YAML timestamp, stored as the string {text!r}: the config "
|
|
200
|
+
"has no timestamp type. Drop the !!timestamp tag to keep it as written.",
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
return text
|
|
204
|
+
|
|
205
|
+
if isinstance(value, bytes):
|
|
206
|
+
raise ConfigDirectoryError(
|
|
207
|
+
f"{source}: {path} is !!binary, which the config has no type for. "
|
|
208
|
+
"Decoding it back to text would be a guess: quote the value if you "
|
|
209
|
+
"meant the string, or drop the key."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if isinstance(value, float) and not isfinite(value):
|
|
213
|
+
raise ConfigDirectoryError(
|
|
214
|
+
f"{source}: {path} is {value}, which JSON has no number for "
|
|
215
|
+
"(`Infinity` and `NaN` are not JSON, and the backend stores the "
|
|
216
|
+
"config as jsonb). Use a finite number, or quote it as a string."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if value is None or isinstance(value, (str, bool, int, float)):
|
|
220
|
+
return value
|
|
221
|
+
|
|
222
|
+
raise ConfigDirectoryError(
|
|
223
|
+
f"{source}: {path} is a {type(value).__name__}, which JSON cannot carry."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _json_sequence(value, *, source: str, path: str, report: Report) -> list:
|
|
228
|
+
"""A list, a !!set or an !!omap/!!pairs tuple, as a JSON array.
|
|
229
|
+
|
|
230
|
+
`sorted(..., key=repr)` because a set has no order to preserve and members
|
|
231
|
+
of mixed types are not comparable to each other: the point is only that two
|
|
232
|
+
reads of the same file produce the same list.
|
|
233
|
+
"""
|
|
234
|
+
members = value
|
|
235
|
+
if isinstance(value, (set, frozenset)):
|
|
236
|
+
report(
|
|
237
|
+
Normalization(
|
|
238
|
+
source, path, "is a YAML set, stored as a sorted list: JSON has no set."
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
members = sorted(value, key=repr)
|
|
242
|
+
elif isinstance(value, tuple):
|
|
243
|
+
report(
|
|
244
|
+
Normalization(
|
|
245
|
+
source, path, "is a YAML pair, stored as a list: JSON has no tuple."
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return [
|
|
250
|
+
_json_value(item, source=source, path=f"{path}[{index}]", report=report)
|
|
251
|
+
for index, item in enumerate(members)
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
|
|
67
255
|
def _file_stem(name: str) -> str:
|
|
68
256
|
return "".join(c if c in _SAFE_FILENAME_CHARS else "_" for c in name) or "unnamed"
|
|
69
257
|
|
|
@@ -86,9 +274,9 @@ def _write_yaml(path: Path, data: dict) -> None:
|
|
|
86
274
|
)
|
|
87
275
|
|
|
88
276
|
|
|
89
|
-
def _load_yaml(path: Path) -> dict:
|
|
277
|
+
def _load_yaml(path: Path, report: Report = _ignore) -> dict:
|
|
90
278
|
try:
|
|
91
|
-
loaded = yaml.
|
|
279
|
+
loaded = yaml.load(path.read_text(), Loader=_ConfigLoader)
|
|
92
280
|
except yaml.YAMLError as exc:
|
|
93
281
|
# A hand-edited file with a typo is the common case here, and a raw
|
|
94
282
|
# ScannerError traceback names neither the file nor the fix.
|
|
@@ -97,7 +285,7 @@ def _load_yaml(path: Path) -> dict:
|
|
|
97
285
|
return {}
|
|
98
286
|
if not isinstance(loaded, dict):
|
|
99
287
|
raise ConfigDirectoryError(f"{path} must contain a YAML mapping, got {type(loaded).__name__}")
|
|
100
|
-
return loaded
|
|
288
|
+
return _json_value(loaded, source=str(path), path="", report=report)
|
|
101
289
|
|
|
102
290
|
|
|
103
291
|
def canonical_config(config: dict) -> dict:
|
|
@@ -158,13 +346,20 @@ def write_config_directory(output_dir: Path, config: dict) -> None:
|
|
|
158
346
|
stale.unlink()
|
|
159
347
|
|
|
160
348
|
|
|
161
|
-
def read_config_directory(connector_dir: Path) -> dict:
|
|
162
|
-
"""Read a local connector directory back into a config v3 blob.
|
|
349
|
+
def read_config_directory(connector_dir: Path, *, report: Report | None = None) -> dict:
|
|
350
|
+
"""Read a local connector directory back into a config v3 blob.
|
|
351
|
+
|
|
352
|
+
`report` is called once per value that could not reach the API as written
|
|
353
|
+
(see `Normalization`). Commands pass a printer: the CLI says what it
|
|
354
|
+
changed instead of changing it quietly. Omitting it discards the notes,
|
|
355
|
+
which is only ever right for a caller that has already been told.
|
|
356
|
+
"""
|
|
357
|
+
report = report or _ignore
|
|
163
358
|
config_path = connector_dir / CONFIG_FILE_NAME
|
|
164
359
|
if not config_path.exists():
|
|
165
360
|
raise ConfigDirectoryError(f"No {CONFIG_FILE_NAME} found in {connector_dir}")
|
|
166
361
|
|
|
167
|
-
config = _load_yaml(config_path)
|
|
362
|
+
config = _load_yaml(config_path, report)
|
|
168
363
|
for key in COLLECTION_DIRS:
|
|
169
364
|
if key in config:
|
|
170
365
|
raise ConfigDirectoryError(
|
|
@@ -176,7 +371,7 @@ def read_config_directory(connector_dir: Path) -> dict:
|
|
|
176
371
|
for key, dir_name in COLLECTION_DIRS.items():
|
|
177
372
|
collection_dir = connector_dir / dir_name
|
|
178
373
|
items = [
|
|
179
|
-
_load_yaml(path) for path in sorted(collection_dir.glob("*.yaml"))
|
|
374
|
+
_load_yaml(path, report) for path in sorted(collection_dir.glob("*.yaml"))
|
|
180
375
|
] if collection_dir.is_dir() else []
|
|
181
376
|
for item in items:
|
|
182
377
|
_item_name(item, key, f"{dir_name}/")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plugsync-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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,24 +1,25 @@
|
|
|
1
|
-
plugsync_cli/__init__.py,sha256=
|
|
1
|
+
plugsync_cli/__init__.py,sha256=YqKMzI6PXaThQdfy4ni_oa1Sw1_ntpXuq3t1SJ9wBlM,75
|
|
2
2
|
plugsync_cli/bundler.py,sha256=nCskRqAILHe3LLMZSyS4klBbM879yBvVJODpZvEEm38,2219
|
|
3
|
-
plugsync_cli/client.py,sha256=
|
|
3
|
+
plugsync_cli/client.py,sha256=DfcmWtaX69WuWngacmiV6vk6_xZqRVlZq2c6uYyT1PE,13966
|
|
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=
|
|
6
|
+
plugsync_cli/context.py,sha256=RsKFNuEpKINExjyU-fKng6IIR9L-x8tr4TiCRHgIazI,7810
|
|
7
7
|
plugsync_cli/findings.py,sha256=V4A60XI2rtfLTFj7kpGJivVFiUfIhu50N3FdCGRnmbU,5005
|
|
8
8
|
plugsync_cli/main.py,sha256=nUy6awLOZ4Uy5YW6bDUhjsvBgCet0SCM2_rrlgnL304,3805
|
|
9
|
-
plugsync_cli/
|
|
9
|
+
plugsync_cli/schema_defaults.py,sha256=tSMmm9cjQlj2YvN7Q20s0rwoHjiOdH6CLttlfbLzmaE,8089
|
|
10
|
+
plugsync_cli/serializer.py,sha256=93l_C88PsCj1fPMZMSTLXMY_D-bfFgpvjsDWiWwwJaQ,17353
|
|
10
11
|
plugsync_cli/commands/__init__.py,sha256=wgYTWnZKXF-4M9ITNDhZMTUJH6_FthcUt_5zGj9NS8I,20
|
|
11
12
|
plugsync_cli/commands/auth.py,sha256=cAcN8ALfkEOcejB4s4zq19dxS6v6iAw691B0HPTQd-Q,5590
|
|
12
|
-
plugsync_cli/commands/diff.py,sha256=
|
|
13
|
+
plugsync_cli/commands/diff.py,sha256=9XR6heMWXv3PhBEP9yH2UvgVqBb5UeDXchAvIQhRwpU,17821
|
|
13
14
|
plugsync_cli/commands/log.py,sha256=49GBgAvvs07d-OwMEgKKc7WalCKH2xmr5l7Ov6N13jM,3376
|
|
14
|
-
plugsync_cli/commands/plugin.py,sha256=
|
|
15
|
+
plugsync_cli/commands/plugin.py,sha256=as1BM6lxpPcdrE12D--BCbSiriCLJbZED8utqJIGos8,20018
|
|
15
16
|
plugsync_cli/commands/preview.py,sha256=yAgXnVsViyaHS8uk4akP7OgJEmCyFUmKSbeW2S0AhBs,2534
|
|
16
17
|
plugsync_cli/commands/pull.py,sha256=UPD7riTKIo-bCiDqrW2v_Ipsb5AgOQxlOqsEeSvl7nU,2624
|
|
17
|
-
plugsync_cli/commands/push.py,sha256=
|
|
18
|
+
plugsync_cli/commands/push.py,sha256=j8yGJ_1j6da_0Xz4UI6im2b3G3_SF8z8VfidPFrnCkg,5160
|
|
18
19
|
plugsync_cli/commands/rollback.py,sha256=OtBk4U7yZubNVwhRmtyX0tSl20oWq8Pcy1Apjj_Ep-0,2471
|
|
19
20
|
plugsync_cli/commands/validate.py,sha256=1ZjGSSkb21_2URmlyNaBBZXh-MI4L6cU_biKwJ6Itr4,1670
|
|
20
|
-
plugsync_cli-0.
|
|
21
|
-
plugsync_cli-0.
|
|
22
|
-
plugsync_cli-0.
|
|
23
|
-
plugsync_cli-0.
|
|
24
|
-
plugsync_cli-0.
|
|
21
|
+
plugsync_cli-0.4.0.dist-info/METADATA,sha256=QCx-iRUs178TdYKA3Ez67sN4KzEbdJ2xVA8wyTP9Lt4,4152
|
|
22
|
+
plugsync_cli-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
23
|
+
plugsync_cli-0.4.0.dist-info/entry_points.txt,sha256=qWyWFQxY9W7L0hEwoDFjQjgs7TxJMsETk2nIe8jCA9E,51
|
|
24
|
+
plugsync_cli-0.4.0.dist-info/top_level.txt,sha256=tShdp15OlzUttfE_QlFMLIfwQfImdp4YVhmB5rXZCgA,13
|
|
25
|
+
plugsync_cli-0.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|