plugsync-cli 0.6.0__py3-none-any.whl → 0.8.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 +92 -0
- plugsync_cli/commands/connector.py +407 -1
- plugsync_cli/commands/diff.py +16 -27
- plugsync_cli/commands/plugin.py +10 -0
- plugsync_cli/commands/push.py +30 -0
- plugsync_cli/context.py +32 -45
- plugsync_cli/serializer.py +54 -24
- {plugsync_cli-0.6.0.dist-info → plugsync_cli-0.8.0.dist-info}/METADATA +13 -11
- {plugsync_cli-0.6.0.dist-info → plugsync_cli-0.8.0.dist-info}/RECORD +14 -13
- plugsync_cli-0.8.0.dist-info/licenses/LICENSE +35 -0
- {plugsync_cli-0.6.0.dist-info → plugsync_cli-0.8.0.dist-info}/WHEEL +0 -0
- {plugsync_cli-0.6.0.dist-info → plugsync_cli-0.8.0.dist-info}/entry_points.txt +0 -0
- {plugsync_cli-0.6.0.dist-info → plugsync_cli-0.8.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.8.0"
|
plugsync_cli/client.py
CHANGED
|
@@ -145,6 +145,98 @@ class PlugSyncClient:
|
|
|
145
145
|
r.raise_for_status()
|
|
146
146
|
return r.json()
|
|
147
147
|
|
|
148
|
+
def prewarm_start(
|
|
149
|
+
self,
|
|
150
|
+
connector_id: str,
|
|
151
|
+
*,
|
|
152
|
+
dry_run: bool = False,
|
|
153
|
+
modified_after: str | None = None,
|
|
154
|
+
entities: list[str] | None = None,
|
|
155
|
+
store_id: str | None = None,
|
|
156
|
+
) -> dict:
|
|
157
|
+
"""Start an identity map pre-warm job (#1885), returning the job.
|
|
158
|
+
|
|
159
|
+
The API answers 202 with the job already running in the background;
|
|
160
|
+
progress is read back with `prewarm_get`. `modified_after` is an ISO
|
|
161
|
+
8601 timestamp with a timezone and turns the run into an incremental
|
|
162
|
+
pass. Unset options are left out of the body so the server defaults
|
|
163
|
+
apply.
|
|
164
|
+
"""
|
|
165
|
+
body: dict = {"dry_run": dry_run}
|
|
166
|
+
if modified_after is not None:
|
|
167
|
+
body["modified_after"] = modified_after
|
|
168
|
+
if entities:
|
|
169
|
+
body["entities"] = list(entities)
|
|
170
|
+
if store_id is not None:
|
|
171
|
+
body["store_id"] = store_id
|
|
172
|
+
with self._client() as c:
|
|
173
|
+
r = c.post(
|
|
174
|
+
self._url(f"/connectors/{connector_id}/identity-prewarm"), json=body
|
|
175
|
+
)
|
|
176
|
+
r.raise_for_status()
|
|
177
|
+
return r.json()
|
|
178
|
+
|
|
179
|
+
def prewarm_list(self, connector_id: str) -> list[dict]:
|
|
180
|
+
"""The connector's most recent pre-warm jobs, newest first."""
|
|
181
|
+
with self._client() as c:
|
|
182
|
+
r = c.get(self._url(f"/connectors/{connector_id}/identity-prewarm"))
|
|
183
|
+
r.raise_for_status()
|
|
184
|
+
return r.json().get("jobs", [])
|
|
185
|
+
|
|
186
|
+
def prewarm_get(self, connector_id: str, job_id: str) -> dict:
|
|
187
|
+
"""One pre-warm job with its per-entity progress."""
|
|
188
|
+
with self._client() as c:
|
|
189
|
+
r = c.get(
|
|
190
|
+
self._url(f"/connectors/{connector_id}/identity-prewarm/{job_id}")
|
|
191
|
+
)
|
|
192
|
+
r.raise_for_status()
|
|
193
|
+
return r.json()
|
|
194
|
+
|
|
195
|
+
def prewarm_findings(
|
|
196
|
+
self,
|
|
197
|
+
connector_id: str,
|
|
198
|
+
job_id: str,
|
|
199
|
+
*,
|
|
200
|
+
finding: str | None = None,
|
|
201
|
+
limit: int = 200,
|
|
202
|
+
offset: int = 0,
|
|
203
|
+
) -> dict:
|
|
204
|
+
"""A page of a job's findings: ambiguous values and conflicting mappings."""
|
|
205
|
+
params: dict = {"limit": limit, "offset": offset}
|
|
206
|
+
if finding is not None:
|
|
207
|
+
params["finding"] = finding
|
|
208
|
+
with self._client() as c:
|
|
209
|
+
r = c.get(
|
|
210
|
+
self._url(
|
|
211
|
+
f"/connectors/{connector_id}/identity-prewarm/{job_id}/findings"
|
|
212
|
+
),
|
|
213
|
+
params=params,
|
|
214
|
+
)
|
|
215
|
+
r.raise_for_status()
|
|
216
|
+
return r.json()
|
|
217
|
+
|
|
218
|
+
def prewarm_resume(self, connector_id: str, job_id: str) -> dict:
|
|
219
|
+
"""Resume an interrupted or failed pre-warm job from its cursor."""
|
|
220
|
+
with self._client() as c:
|
|
221
|
+
r = c.post(
|
|
222
|
+
self._url(
|
|
223
|
+
f"/connectors/{connector_id}/identity-prewarm/{job_id}/resume"
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
r.raise_for_status()
|
|
227
|
+
return r.json()
|
|
228
|
+
|
|
229
|
+
def prewarm_cancel(self, connector_id: str, job_id: str) -> dict:
|
|
230
|
+
"""Cancel a pre-warm job for good: it cannot be resumed afterwards."""
|
|
231
|
+
with self._client() as c:
|
|
232
|
+
r = c.post(
|
|
233
|
+
self._url(
|
|
234
|
+
f"/connectors/{connector_id}/identity-prewarm/{job_id}/cancel"
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
r.raise_for_status()
|
|
238
|
+
return r.json()
|
|
239
|
+
|
|
148
240
|
def get_draft(self, connector_id: str) -> dict:
|
|
149
241
|
"""The connector's editable config (backend DraftResponse).
|
|
150
242
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""plugsync connector --
|
|
1
|
+
"""plugsync connector -- flow circuit breakers and the identity map pre-warm.
|
|
2
2
|
|
|
3
3
|
Self-service only, with the same org-scoped auth as every other command: the
|
|
4
4
|
owner of a connector lists the flows whose circuit breaker is open and retries
|
|
@@ -6,7 +6,14 @@ one of them, from a script or from CI, without opening the dashboard. The
|
|
|
6
6
|
platform-side force-close is a different operation with a different
|
|
7
7
|
authentication contract (it refuses an org API key) and deliberately has no
|
|
8
8
|
command here.
|
|
9
|
+
|
|
10
|
+
`connector prewarm` drives the identity map pre-warm of a migration cutover
|
|
11
|
+
(#1885): it fills the connector's identity map from records already on the
|
|
12
|
+
HubSpot portal, so the first events after go-live update them instead of
|
|
13
|
+
searching for them. The job runs server-side; these commands start it, read
|
|
14
|
+
its progress, resume it and list what it could not map.
|
|
9
15
|
"""
|
|
16
|
+
import time
|
|
10
17
|
import uuid
|
|
11
18
|
from datetime import datetime, timezone
|
|
12
19
|
|
|
@@ -251,3 +258,402 @@ def connector_breakers(connector_reference: str):
|
|
|
251
258
|
"by the next message it receives.\n",
|
|
252
259
|
soft_wrap=True,
|
|
253
260
|
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# Identity map pre-warm (#1885)
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
_PREWARM_TERMINAL = frozenset({
|
|
268
|
+
"completed", "partial_failure", "failed", "cancelled", "superseded", "expired",
|
|
269
|
+
})
|
|
270
|
+
_PREWARM_POLL_SECONDS = 5
|
|
271
|
+
# --wait outlives a deploy of the API or a network blip: the job runs on the
|
|
272
|
+
# server either way. This many polls in a row may fail before it gives up.
|
|
273
|
+
_PREWARM_POLL_MAX_FAILURES = 12
|
|
274
|
+
|
|
275
|
+
# Machine codes of the pre-warm API whose own message is the whole story.
|
|
276
|
+
_PREWARM_ERRORS = frozenset({
|
|
277
|
+
"no_active_config",
|
|
278
|
+
"no_eligible_entities",
|
|
279
|
+
"entity_not_eligible",
|
|
280
|
+
"prewarm_already_running",
|
|
281
|
+
"job_not_resumable",
|
|
282
|
+
"job_not_cancellable",
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _structured_detail(exc: httpx.HTTPStatusError) -> dict:
|
|
287
|
+
try:
|
|
288
|
+
payload = exc.response.json()
|
|
289
|
+
except ValueError:
|
|
290
|
+
return {}
|
|
291
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
292
|
+
return detail if isinstance(detail, dict) else {}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _prewarm_call(label: str, call, *args, **kwargs):
|
|
296
|
+
"""Run one pre-warm request, turning the API's refusals into sentences."""
|
|
297
|
+
try:
|
|
298
|
+
return call(*args, **kwargs)
|
|
299
|
+
except httpx.HTTPStatusError as exc:
|
|
300
|
+
detail = _structured_detail(exc)
|
|
301
|
+
code = detail.get("error")
|
|
302
|
+
if exc.response.status_code == 404:
|
|
303
|
+
if code == "job_not_found":
|
|
304
|
+
fail(f"Pre-warm job not found on connector '{label}'.")
|
|
305
|
+
fail(_CONNECTOR_NOT_FOUND.format(label=label))
|
|
306
|
+
if code in _PREWARM_ERRORS:
|
|
307
|
+
message = detail.get("message") or code
|
|
308
|
+
if code == "entity_not_eligible":
|
|
309
|
+
message += (
|
|
310
|
+
f" Not eligible: {', '.join(detail.get('entities') or [])}."
|
|
311
|
+
f" Eligible: {', '.join(detail.get('eligible') or []) or 'none'}."
|
|
312
|
+
)
|
|
313
|
+
fail(f"{message} ({code})")
|
|
314
|
+
raise
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _render_job(job: dict) -> None:
|
|
318
|
+
"""A job's header line and its per-entity counters."""
|
|
319
|
+
mode = "dry-run" if job.get("dry_run") else "write"
|
|
320
|
+
scope = (
|
|
321
|
+
f"incremental after {job['modified_after']}"
|
|
322
|
+
if job.get("modified_after") else "full"
|
|
323
|
+
)
|
|
324
|
+
store = f", store '{job['store_id']}'" if job.get("store_id") else ""
|
|
325
|
+
console.print(
|
|
326
|
+
f"\n[bold]Pre-warm {job.get('job_id')}[/bold]: {job.get('status')} "
|
|
327
|
+
f"({mode}, {scope}{store})",
|
|
328
|
+
soft_wrap=True,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# Single-cell padding: eight columns at the default two overflow an
|
|
332
|
+
# 80-column terminal and rich starts truncating the counters.
|
|
333
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
|
|
334
|
+
table.add_column("Entity", overflow="fold")
|
|
335
|
+
table.add_column("Phase")
|
|
336
|
+
for column in ("Scanned", "Mapped", "Present", "Conflicts", "Ambiguous", "Skipped"):
|
|
337
|
+
table.add_column(column, justify="right")
|
|
338
|
+
skipped_notes = []
|
|
339
|
+
for entry in job.get("entities") or []:
|
|
340
|
+
missing = entry.get("missing_value", 0)
|
|
341
|
+
invalid = entry.get("invalid_value", 0)
|
|
342
|
+
unmodified = entry.get("not_modified", 0)
|
|
343
|
+
skipped = missing + invalid + unmodified
|
|
344
|
+
table.add_row(
|
|
345
|
+
str(entry.get("entity") or ""),
|
|
346
|
+
str(entry.get("phase") or ""),
|
|
347
|
+
str(entry.get("scanned", 0)),
|
|
348
|
+
str(entry.get("mapped", 0)),
|
|
349
|
+
str(entry.get("already_present", 0)),
|
|
350
|
+
str(entry.get("conflicting", 0)),
|
|
351
|
+
str(entry.get("ambiguous_values", 0)),
|
|
352
|
+
str(skipped),
|
|
353
|
+
)
|
|
354
|
+
if skipped:
|
|
355
|
+
skipped_notes.append(
|
|
356
|
+
f"{entry.get('entity')}: skipped {missing} without an identity value, "
|
|
357
|
+
f"{invalid} with an invalid one, {unmodified} not modified since "
|
|
358
|
+
"the cutoff."
|
|
359
|
+
)
|
|
360
|
+
if entry.get("mapped_elsewhere"):
|
|
361
|
+
skipped_notes.append(
|
|
362
|
+
f"{entry.get('entity')}: {entry['mapped_elsewhere']} records already "
|
|
363
|
+
"mapped under another external id, not mapped again."
|
|
364
|
+
)
|
|
365
|
+
if entry.get("ambiguous_values"):
|
|
366
|
+
skipped_notes.append(
|
|
367
|
+
f"{entry.get('entity')}: {entry['ambiguous_values']} ambiguous values "
|
|
368
|
+
f"held by {entry.get('ambiguous_records', 0)} records, not mapped."
|
|
369
|
+
)
|
|
370
|
+
console.print(table)
|
|
371
|
+
for note in skipped_notes:
|
|
372
|
+
console.print(note, soft_wrap=True)
|
|
373
|
+
|
|
374
|
+
for entry in job.get("entities") or []:
|
|
375
|
+
if entry.get("error"):
|
|
376
|
+
console.print(
|
|
377
|
+
f"[red]{entry.get('entity')}: {entry['error']}[/red]", soft_wrap=True
|
|
378
|
+
)
|
|
379
|
+
if job.get("last_error"):
|
|
380
|
+
console.print(f"[red]Job error: {job['last_error']}[/red]", soft_wrap=True)
|
|
381
|
+
if job.get("dry_run") and job.get("status") in _PREWARM_TERMINAL:
|
|
382
|
+
console.print("Dry run: nothing was written. 'Mapped' is what a real run would write.")
|
|
383
|
+
if job.get("resumable"):
|
|
384
|
+
console.print(
|
|
385
|
+
f"Resumable from where it stopped: plugsync connector prewarm resume "
|
|
386
|
+
f"<connector> {job.get('job_id')}",
|
|
387
|
+
soft_wrap=True,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _has_findings(job: dict) -> bool:
|
|
392
|
+
return any(
|
|
393
|
+
(entry.get("ambiguous_values") or 0) or (entry.get("conflicting") or 0)
|
|
394
|
+
or (entry.get("mapped_elsewhere") or 0)
|
|
395
|
+
for entry in job.get("entities") or []
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _is_transient(exc: Exception) -> bool:
|
|
400
|
+
"""A failed poll that says nothing about the job: the API restarting
|
|
401
|
+
(5xx), a rate limit, the network."""
|
|
402
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
403
|
+
return exc.response.status_code >= 500 or exc.response.status_code == 429
|
|
404
|
+
return isinstance(exc, httpx.TransportError)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _wait_for(client: PlugSyncClient, connector_id: str, label: str, job: dict) -> dict:
|
|
408
|
+
"""Poll a job until it reaches a terminal status.
|
|
409
|
+
|
|
410
|
+
A poll that fails for a transient reason is tried again at the next
|
|
411
|
+
tick: the job runs on the server and is not affected, so exiting
|
|
412
|
+
non-zero here would report a failure that did not happen. After
|
|
413
|
+
`_PREWARM_POLL_MAX_FAILURES` failed polls in a row it gives up, saying
|
|
414
|
+
that the job's state is unknown, not that it failed.
|
|
415
|
+
"""
|
|
416
|
+
failures = 0
|
|
417
|
+
while job.get("status") not in _PREWARM_TERMINAL:
|
|
418
|
+
if job.get("resumable"):
|
|
419
|
+
# A running job whose heartbeat went stale: nobody is advancing it.
|
|
420
|
+
break
|
|
421
|
+
time.sleep(_PREWARM_POLL_SECONDS)
|
|
422
|
+
try:
|
|
423
|
+
job = _prewarm_call(label, client.prewarm_get, connector_id, str(job["job_id"]))
|
|
424
|
+
except (httpx.HTTPStatusError, httpx.TransportError) as exc:
|
|
425
|
+
if not _is_transient(exc):
|
|
426
|
+
raise
|
|
427
|
+
failures += 1
|
|
428
|
+
reason = (
|
|
429
|
+
f"HTTP {exc.response.status_code}"
|
|
430
|
+
if isinstance(exc, httpx.HTTPStatusError) else type(exc).__name__
|
|
431
|
+
)
|
|
432
|
+
if failures >= _PREWARM_POLL_MAX_FAILURES:
|
|
433
|
+
fail(
|
|
434
|
+
f"Lost contact with the API while waiting ({failures} polls "
|
|
435
|
+
f"failed in a row, last: {reason}). The pre-warm may still be "
|
|
436
|
+
f"running: check it with plugsync connector prewarm status "
|
|
437
|
+
f"{label} {job.get('job_id')}"
|
|
438
|
+
)
|
|
439
|
+
console.print(
|
|
440
|
+
f"[yellow]Status poll failed ({reason}), retrying "
|
|
441
|
+
f"({failures}/{_PREWARM_POLL_MAX_FAILURES}).[/yellow]",
|
|
442
|
+
soft_wrap=True,
|
|
443
|
+
)
|
|
444
|
+
continue
|
|
445
|
+
failures = 0
|
|
446
|
+
return job
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _finish(job: dict, label: str) -> None:
|
|
450
|
+
"""Report a job `--wait` stopped on; exit non-zero unless it completed."""
|
|
451
|
+
_render_job(job)
|
|
452
|
+
if _has_findings(job):
|
|
453
|
+
console.print(
|
|
454
|
+
f"Review what was not mapped: plugsync connector prewarm findings "
|
|
455
|
+
f"{label} {job.get('job_id')}",
|
|
456
|
+
soft_wrap=True,
|
|
457
|
+
)
|
|
458
|
+
status = job.get("status")
|
|
459
|
+
if status not in _PREWARM_TERMINAL:
|
|
460
|
+
# --wait gave up on a job nobody is advancing: a script chaining the
|
|
461
|
+
# cutover on this exit code must not read that as a finished pre-warm.
|
|
462
|
+
console.print(
|
|
463
|
+
f"[red]The pre-warm stopped advancing before it finished "
|
|
464
|
+
f"(status: {status}).[/red]",
|
|
465
|
+
soft_wrap=True,
|
|
466
|
+
)
|
|
467
|
+
if status != "completed":
|
|
468
|
+
raise SystemExit(1)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@connector.group()
|
|
472
|
+
def prewarm():
|
|
473
|
+
"""Pre-warm a connector's identity map from its HubSpot portal."""
|
|
474
|
+
pass
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@prewarm.command("start")
|
|
478
|
+
@click.argument("connector_reference", metavar="CONNECTOR")
|
|
479
|
+
@click.option("--dry-run", is_flag=True,
|
|
480
|
+
help="Count per entity what would be mapped, write nothing.")
|
|
481
|
+
@click.option("--modified-after", metavar="TIMESTAMP",
|
|
482
|
+
help="Incremental pass: map only records modified after this ISO 8601 "
|
|
483
|
+
"timestamp, with a timezone (e.g. 2026-10-01T08:00:00Z).")
|
|
484
|
+
@click.option("--entity", "entities", multiple=True, metavar="ENTITY",
|
|
485
|
+
help="Limit the run to this entity. Repeatable. Default: every entity "
|
|
486
|
+
"with a HubSpot schema and an identity property.")
|
|
487
|
+
@click.option("--store-id", metavar="STORE",
|
|
488
|
+
help="Store id of the keys written. Only for records that will arrive "
|
|
489
|
+
"through bulk import with that storeId; event flows need none.")
|
|
490
|
+
@click.option("--wait", is_flag=True,
|
|
491
|
+
help="Poll until the job finishes and exit non-zero if it did not complete.")
|
|
492
|
+
def prewarm_start(connector_reference: str, dry_run: bool, modified_after: str | None,
|
|
493
|
+
entities: tuple[str, ...], store_id: str | None, wait: bool):
|
|
494
|
+
"""Start an identity map pre-warm.
|
|
495
|
+
|
|
496
|
+
CONNECTOR is a connector name or id. The job reads every record of the
|
|
497
|
+
eligible entities from HubSpot and writes the identity mappings that are
|
|
498
|
+
missing. Existing mappings are never overwritten, and a value held by more
|
|
499
|
+
than one HubSpot record is reported as ambiguous instead of mapped.
|
|
500
|
+
|
|
501
|
+
Examples:
|
|
502
|
+
plugsync connector prewarm start my-connector --dry-run --wait
|
|
503
|
+
plugsync connector prewarm start my-connector
|
|
504
|
+
plugsync connector prewarm start my-connector --modified-after 2026-10-01T08:00:00Z --wait
|
|
505
|
+
"""
|
|
506
|
+
if modified_after is not None:
|
|
507
|
+
try:
|
|
508
|
+
parsed = datetime.fromisoformat(modified_after.replace("Z", "+00:00"))
|
|
509
|
+
except ValueError:
|
|
510
|
+
fail(f"--modified-after '{modified_after}' is not an ISO 8601 timestamp.")
|
|
511
|
+
if parsed.tzinfo is None:
|
|
512
|
+
fail("--modified-after needs a timezone, e.g. 2026-10-01T08:00:00Z.")
|
|
513
|
+
|
|
514
|
+
client = build_client()
|
|
515
|
+
connector_id, label = _resolve_connector(client, connector_reference)
|
|
516
|
+
job = _prewarm_call(
|
|
517
|
+
label, client.prewarm_start, connector_id,
|
|
518
|
+
dry_run=dry_run, modified_after=modified_after,
|
|
519
|
+
entities=list(entities) or None, store_id=store_id,
|
|
520
|
+
)
|
|
521
|
+
if not wait:
|
|
522
|
+
_render_job(job)
|
|
523
|
+
console.print(
|
|
524
|
+
f"Follow it with: plugsync connector prewarm status {label} {job.get('job_id')}",
|
|
525
|
+
soft_wrap=True,
|
|
526
|
+
)
|
|
527
|
+
return
|
|
528
|
+
_finish(_wait_for(client, connector_id, label, job), label)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@prewarm.command("status")
|
|
532
|
+
@click.argument("connector_reference", metavar="CONNECTOR")
|
|
533
|
+
@click.argument("job_id", required=False)
|
|
534
|
+
@click.option("--wait", is_flag=True, help="Poll until the job finishes.")
|
|
535
|
+
def prewarm_status(connector_reference: str, job_id: str | None, wait: bool):
|
|
536
|
+
"""Show a pre-warm job's progress (default: the most recent job).
|
|
537
|
+
|
|
538
|
+
Examples:
|
|
539
|
+
plugsync connector prewarm status my-connector
|
|
540
|
+
plugsync connector prewarm status my-connector <job-id> --wait
|
|
541
|
+
"""
|
|
542
|
+
client = build_client()
|
|
543
|
+
connector_id, label = _resolve_connector(client, connector_reference)
|
|
544
|
+
if job_id is None:
|
|
545
|
+
jobs = _prewarm_call(label, client.prewarm_list, connector_id)
|
|
546
|
+
if not jobs:
|
|
547
|
+
console.print(f"No pre-warm job on '{label}' yet.")
|
|
548
|
+
return
|
|
549
|
+
job = jobs[0]
|
|
550
|
+
else:
|
|
551
|
+
job = _prewarm_call(label, client.prewarm_get, connector_id, job_id)
|
|
552
|
+
if wait:
|
|
553
|
+
_finish(_wait_for(client, connector_id, label, job), label)
|
|
554
|
+
return
|
|
555
|
+
_render_job(job)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@prewarm.command("resume")
|
|
559
|
+
@click.argument("connector_reference", metavar="CONNECTOR")
|
|
560
|
+
@click.argument("job_id")
|
|
561
|
+
@click.option("--wait", is_flag=True, help="Poll until the job finishes.")
|
|
562
|
+
def prewarm_resume(connector_reference: str, job_id: str, wait: bool):
|
|
563
|
+
"""Resume a failed or interrupted pre-warm from its last saved page.
|
|
564
|
+
|
|
565
|
+
A job can be resumed when it failed (for example after a long HubSpot
|
|
566
|
+
rate limit) or when it is marked running but its heartbeat is older than
|
|
567
|
+
five minutes (the process running it stopped). Only the connector's
|
|
568
|
+
latest job can be resumed, and never a cancelled, superseded or expired
|
|
569
|
+
one: start a new pre-warm instead.
|
|
570
|
+
"""
|
|
571
|
+
client = build_client()
|
|
572
|
+
connector_id, label = _resolve_connector(client, connector_reference)
|
|
573
|
+
job = _prewarm_call(label, client.prewarm_resume, connector_id, job_id)
|
|
574
|
+
if wait:
|
|
575
|
+
_finish(_wait_for(client, connector_id, label, job), label)
|
|
576
|
+
return
|
|
577
|
+
_render_job(job)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
@prewarm.command("cancel")
|
|
581
|
+
@click.argument("connector_reference", metavar="CONNECTOR")
|
|
582
|
+
@click.argument("job_id")
|
|
583
|
+
def prewarm_cancel(connector_reference: str, job_id: str):
|
|
584
|
+
"""Cancel a pre-warm job for good.
|
|
585
|
+
|
|
586
|
+
Works on a queued, running or failed job. Use it to stop a run, or to
|
|
587
|
+
free a connector whose job is stuck in `running` because the process
|
|
588
|
+
running it stopped: until then no new pre-warm can start. A running job
|
|
589
|
+
stops at its next page. The mappings already written stay (a later run
|
|
590
|
+
finds them present); the job's staged values are deleted and it can
|
|
591
|
+
never be resumed.
|
|
592
|
+
|
|
593
|
+
Example:
|
|
594
|
+
plugsync connector prewarm cancel my-connector <job-id>
|
|
595
|
+
"""
|
|
596
|
+
client = build_client()
|
|
597
|
+
connector_id, label = _resolve_connector(client, connector_reference)
|
|
598
|
+
job = _prewarm_call(label, client.prewarm_cancel, connector_id, job_id)
|
|
599
|
+
_render_job(job)
|
|
600
|
+
console.print(
|
|
601
|
+
f"Cancelled. Start again with: plugsync connector prewarm start {label}",
|
|
602
|
+
soft_wrap=True,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@prewarm.command("findings")
|
|
607
|
+
@click.argument("connector_reference", metavar="CONNECTOR")
|
|
608
|
+
@click.argument("job_id")
|
|
609
|
+
@click.option("--finding",
|
|
610
|
+
type=click.Choice(["ambiguous", "conflict", "mapped_elsewhere"]),
|
|
611
|
+
help="Only this kind of finding.")
|
|
612
|
+
@click.option("--limit", type=click.IntRange(1, 1000), default=200, show_default=True)
|
|
613
|
+
@click.option("--offset", type=click.IntRange(0), default=0, show_default=True)
|
|
614
|
+
def prewarm_findings(connector_reference: str, job_id: str, finding: str | None,
|
|
615
|
+
limit: int, offset: int):
|
|
616
|
+
"""List what a pre-warm could not map.
|
|
617
|
+
|
|
618
|
+
`ambiguous`: the same identity value is held by several HubSpot records,
|
|
619
|
+
so no mapping was written; deduplicate them on the portal and run the
|
|
620
|
+
pre-warm again. `conflict`: the identity map already maps the value to a
|
|
621
|
+
different record and was left untouched. `mapped_elsewhere`: the record
|
|
622
|
+
is already mapped under another external id (shown in the last column),
|
|
623
|
+
so it was not given a second mapping.
|
|
624
|
+
"""
|
|
625
|
+
client = build_client()
|
|
626
|
+
connector_id, label = _resolve_connector(client, connector_reference)
|
|
627
|
+
page = _prewarm_call(
|
|
628
|
+
label, client.prewarm_findings, connector_id, job_id,
|
|
629
|
+
finding=finding, limit=limit, offset=offset,
|
|
630
|
+
)
|
|
631
|
+
rows = page.get("findings") or []
|
|
632
|
+
total = page.get("total", len(rows))
|
|
633
|
+
if not rows:
|
|
634
|
+
console.print(f"[green]No findings for job {job_id}.[/green]")
|
|
635
|
+
return
|
|
636
|
+
|
|
637
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
638
|
+
table.add_column("Entity")
|
|
639
|
+
table.add_column("Finding")
|
|
640
|
+
table.add_column("Value", overflow="fold")
|
|
641
|
+
table.add_column("HubSpot id")
|
|
642
|
+
table.add_column("Mapped to")
|
|
643
|
+
for row in rows:
|
|
644
|
+
table.add_row(
|
|
645
|
+
str(row.get("entity") or ""),
|
|
646
|
+
str(row.get("finding") or ""),
|
|
647
|
+
str(row.get("value") or ""),
|
|
648
|
+
str(row.get("hubspot_id") or ""),
|
|
649
|
+
str(row.get("existing_hubspot_id") or row.get("existing_external_id") or ""),
|
|
650
|
+
)
|
|
651
|
+
console.print(table)
|
|
652
|
+
shown_to = offset + len(rows)
|
|
653
|
+
console.print(f"\n{offset + 1}-{shown_to} of {total}.")
|
|
654
|
+
if shown_to < total:
|
|
655
|
+
console.print(
|
|
656
|
+
f"Next page: plugsync connector prewarm findings {label} {job_id} "
|
|
657
|
+
f"--offset {shown_to}",
|
|
658
|
+
soft_wrap=True,
|
|
659
|
+
)
|
plugsync_cli/commands/diff.py
CHANGED
|
@@ -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
|
|
plugsync_cli/commands/plugin.py
CHANGED
|
@@ -35,6 +35,16 @@ export async function handler(event: Record<string, unknown>): Promise<object> {
|
|
|
35
35
|
flow_name: event.flow_name,
|
|
36
36
|
}));
|
|
37
37
|
// TODO: implement your plugin logic here
|
|
38
|
+
//
|
|
39
|
+
// The response is a plain object; later flow steps read any key of it as
|
|
40
|
+
// `$<step_id>.<key>`. Keys the runtime interprets:
|
|
41
|
+
// return { action: "skip" }; // stop the flow here
|
|
42
|
+
// return { action: "retry", reason: "HTTP 503" }; // transient, retried
|
|
43
|
+
// return { outcome: "failed", reason: "rejected" }; // target refused the
|
|
44
|
+
// record: later steps still run, the record ends failed and replayable
|
|
45
|
+
// store_writes: [{ key, value, ttl_seconds? }] // needs store:write;
|
|
46
|
+
// keys persist for the life of the connector (deleted with it);
|
|
47
|
+
// ttl_seconds opts a key into expiry
|
|
38
48
|
return {};
|
|
39
49
|
}
|
|
40
50
|
"""
|
plugsync_cli/commands/push.py
CHANGED
|
@@ -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
|
+
)
|
plugsync_cli/context.py
CHANGED
|
@@ -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]"
|
plugsync_cli/serializer.py
CHANGED
|
@@ -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,18 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plugsync-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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
|
-
License: Proprietary
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
7
|
Project-URL: Homepage, https://plugsync.com
|
|
8
|
-
Project-URL: Documentation, https://
|
|
9
|
-
Project-URL:
|
|
10
|
-
Project-URL: Issues, https://github.com/exelab/plugsync/issues
|
|
8
|
+
Project-URL: Documentation, https://docs.plugsync.com/reference/cli/
|
|
9
|
+
Project-URL: Terms of Service, https://plugsync.com/it/terms
|
|
11
10
|
Keywords: plugsync,hubspot,cli,connectors,integration
|
|
12
11
|
Classifier: Development Status :: 4 - Beta
|
|
13
12
|
Classifier: Environment :: Console
|
|
14
13
|
Classifier: Intended Audience :: Developers
|
|
15
|
-
Classifier: License :: Other/Proprietary License
|
|
16
14
|
Classifier: Operating System :: OS Independent
|
|
17
15
|
Classifier: Programming Language :: Python :: 3
|
|
18
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
@@ -20,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
20
18
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
19
|
Requires-Python: >=3.12
|
|
22
20
|
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
23
22
|
Requires-Dist: click>=8.1
|
|
24
23
|
Requires-Dist: httpx>=0.27
|
|
25
24
|
Requires-Dist: pyyaml>=6.0
|
|
@@ -28,6 +27,7 @@ Provides-Extra: dev
|
|
|
28
27
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
28
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
30
29
|
Requires-Dist: respx>=0.21; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
31
|
|
|
32
32
|
# plugsync-cli
|
|
33
33
|
|
|
@@ -91,11 +91,13 @@ against the API at the start of any command that talks to it, warning
|
|
|
91
91
|
## Documentation
|
|
92
92
|
|
|
93
93
|
Full reference docs, including the plugin authoring dev-loop, live at
|
|
94
|
-
[docs.plugsync.com](https://docs.plugsync.com)
|
|
95
|
-
[plugsync repository](https://github.com/exelab/plugsync/tree/main/docs).
|
|
94
|
+
[docs.plugsync.com](https://docs.plugsync.com).
|
|
96
95
|
|
|
97
96
|
## License
|
|
98
97
|
|
|
99
|
-
Proprietary.
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
Proprietary, not open source. The license to install and use this CLI is
|
|
99
|
+
granted by paragraph 11.2 of the plugsync Terms of Service, published at
|
|
100
|
+
[plugsync.com/it/terms](https://plugsync.com/it/terms): a non-exclusive,
|
|
101
|
+
non-transferable, free-of-charge license, for the term of your plugsync
|
|
102
|
+
agreement, solely for the purpose of using the plugsync service. The
|
|
103
|
+
`LICENSE` file shipped with the package carries the full notice.
|
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
plugsync_cli/__init__.py,sha256=
|
|
1
|
+
plugsync_cli/__init__.py,sha256=eDPwXO1lIo5lCvUfCDMdO6mSxWqST0tX30uKPMmCUWU,75
|
|
2
2
|
plugsync_cli/bundler.py,sha256=nCskRqAILHe3LLMZSyS4klBbM879yBvVJODpZvEEm38,2219
|
|
3
|
-
plugsync_cli/client.py,sha256=
|
|
3
|
+
plugsync_cli/client.py,sha256=k_hHOZG99MRwOPti9zCfniFzcIxCvatpBkodR1x3RRU,18402
|
|
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=5sk5_xkuL7LsE0fDitWVsKUArwx18Vyr47mgs-uMrUU,10624
|
|
7
7
|
plugsync_cli/findings.py,sha256=ORWvqV7P8ss4Id846LZwWp5X6xEh7uZl6c86faDXfyQ,5526
|
|
8
8
|
plugsync_cli/main.py,sha256=z_zM0GXm_tq_ZlNdFqlYuNQOk6cCdxsLagWmN95bAdw,3886
|
|
9
9
|
plugsync_cli/schema_defaults.py,sha256=tSMmm9cjQlj2YvN7Q20s0rwoHjiOdH6CLttlfbLzmaE,8089
|
|
10
|
-
plugsync_cli/serializer.py,sha256=
|
|
10
|
+
plugsync_cli/serializer.py,sha256=Nspm510tbgwI_udiPpNeXjO_NVWL-T1VEC9uvpXYqMM,25620
|
|
11
11
|
plugsync_cli/commands/__init__.py,sha256=wgYTWnZKXF-4M9ITNDhZMTUJH6_FthcUt_5zGj9NS8I,20
|
|
12
12
|
plugsync_cli/commands/auth.py,sha256=cAcN8ALfkEOcejB4s4zq19dxS6v6iAw691B0HPTQd-Q,5590
|
|
13
|
-
plugsync_cli/commands/connector.py,sha256=
|
|
14
|
-
plugsync_cli/commands/diff.py,sha256=
|
|
13
|
+
plugsync_cli/commands/connector.py,sha256=_hmxZNMqP7XNo-DD3QreV39UzLr3oRKmLmKd3U0756E,25707
|
|
14
|
+
plugsync_cli/commands/diff.py,sha256=zgTA5FeJLPnr-kiQaUezEyF5Wwq_2Gv4qnKnxvNkA7Q,19664
|
|
15
15
|
plugsync_cli/commands/log.py,sha256=49GBgAvvs07d-OwMEgKKc7WalCKH2xmr5l7Ov6N13jM,3376
|
|
16
|
-
plugsync_cli/commands/plugin.py,sha256=
|
|
16
|
+
plugsync_cli/commands/plugin.py,sha256=ziLaciAVFTCQp18-mwIYaPriWhKR1zWS0Pldxsjp6OQ,20672
|
|
17
17
|
plugsync_cli/commands/preview.py,sha256=7MyGDqyb_6XLiO6-CFyNJDBuD2BXqfKg8Y2U3ax2ptM,2960
|
|
18
18
|
plugsync_cli/commands/pull.py,sha256=UXXaINuTHo3sroXAmfA7BZFQj7eG8OKrWCbvhu1Y05o,2479
|
|
19
|
-
plugsync_cli/commands/push.py,sha256=
|
|
19
|
+
plugsync_cli/commands/push.py,sha256=xZNpRY4wAcTnkkw59Tg18lRoYilFCC-t8SR1RvC5CQA,6520
|
|
20
20
|
plugsync_cli/commands/rollback.py,sha256=OtBk4U7yZubNVwhRmtyX0tSl20oWq8Pcy1Apjj_Ep-0,2471
|
|
21
21
|
plugsync_cli/commands/validate.py,sha256=1ZjGSSkb21_2URmlyNaBBZXh-MI4L6cU_biKwJ6Itr4,1670
|
|
22
|
-
plugsync_cli-0.
|
|
23
|
-
plugsync_cli-0.
|
|
24
|
-
plugsync_cli-0.
|
|
25
|
-
plugsync_cli-0.
|
|
26
|
-
plugsync_cli-0.
|
|
22
|
+
plugsync_cli-0.8.0.dist-info/licenses/LICENSE,sha256=5op7H-LOvR3Worm5Caabx48Er2AUe8Kp0eLCJoIoCYQ,1598
|
|
23
|
+
plugsync_cli-0.8.0.dist-info/METADATA,sha256=QwLX7YQd-W1Ty6bn_phSiXNYbREgIOsihAzQg5N7O-o,4466
|
|
24
|
+
plugsync_cli-0.8.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
25
|
+
plugsync_cli-0.8.0.dist-info/entry_points.txt,sha256=qWyWFQxY9W7L0hEwoDFjQjgs7TxJMsETk2nIe8jCA9E,51
|
|
26
|
+
plugsync_cli-0.8.0.dist-info/top_level.txt,sha256=tShdp15OlzUttfE_QlFMLIfwQfImdp4YVhmB5rXZCgA,13
|
|
27
|
+
plugsync_cli-0.8.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
plugsync-cli
|
|
2
|
+
Copyright (c) 2026 Exelab S.r.l. All rights reserved.
|
|
3
|
+
|
|
4
|
+
This package is the plugsync command-line interface (the "CLI"), client
|
|
5
|
+
software distributed by Exelab S.r.l. ("Exelab") for use with the plugsync
|
|
6
|
+
service. It is not open source software.
|
|
7
|
+
|
|
8
|
+
The right to install and use the CLI is granted by paragraph 11.2 of the
|
|
9
|
+
plugsync Terms of Service (the "Terms"), published at:
|
|
10
|
+
|
|
11
|
+
https://plugsync.com/it/terms
|
|
12
|
+
|
|
13
|
+
Paragraph 11.2 of the Terms reads:
|
|
14
|
+
|
|
15
|
+
11.2 Software client. Per la durata del Contratto Exelab concede al
|
|
16
|
+
Cliente una licenza non esclusiva, non trasferibile e gratuita di
|
|
17
|
+
installare e utilizzare la CLI e il Server MCP al solo fine di utilizzare
|
|
18
|
+
il Servizio, nel rispetto dell'Articolo 6.
|
|
19
|
+
|
|
20
|
+
(Courtesy translation: For the term of the Agreement, Exelab grants the
|
|
21
|
+
Customer a non-exclusive, non-transferable, free-of-charge license to
|
|
22
|
+
install and use the CLI and the MCP Server solely for the purpose of using
|
|
23
|
+
the Service, in compliance with Article 6.)
|
|
24
|
+
|
|
25
|
+
No other right is granted. Exelab remains the sole owner of the CLI and of
|
|
26
|
+
every intellectual property right in it (paragraph 11.1 of the Terms).
|
|
27
|
+
Warranties and liability are governed by the Terms. Capitalised words have
|
|
28
|
+
the meaning given to them in the Terms.
|
|
29
|
+
|
|
30
|
+
The Terms are drafted in Italian, which is the authoritative text; any
|
|
31
|
+
translation, including the one above, is provided as a courtesy only. Where
|
|
32
|
+
this notice and the Terms differ, the Terms prevail.
|
|
33
|
+
|
|
34
|
+
Third-party components that the CLI depends on are distributed under their
|
|
35
|
+
own open source licenses, which continue to govern them.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|