Skip to content

Commit d3b5957

Browse files
committed
feat: implement conflict-aware draft updates (release 0.10.0)
1 parent 643381d commit d3b5957

18 files changed

Lines changed: 1103 additions & 41 deletions

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# Changelog
22

3+
## 0.10.0
4+
5+
### Added
6+
7+
- Export a stable remote revision fingerprint in Markdown backup metadata via
8+
`python-substack-revision:v1` markers.
9+
- Conflict-aware draft updates: compare submitted expected revision with current
10+
remote draft before any write, preventing silent overwrites of remote changes.
11+
- Return actionable mismatch details (body changes, title, subtitle, audience,
12+
permissions, section, slug) during dry-run updates (`--dry-run --yes`).
13+
- Fail closed by default on conflicted updates or missing revision metadata
14+
(unprotected updates).
15+
- Add explicit override via `--allow-conflict` / `--force` (requiring `--yes`)
16+
in CLI and `allow_conflict=True` / `force=True` in SDK.
17+
- Export `revision` dictionary metadata in `Api.export_draft_to_markdown` and
18+
CLI `--json drafts export`.
19+
- Introduce `DraftConflictError` exception inheriting from both `ValueError` and
20+
`SubstackException`.
21+
322
## 0.9.1
423

524
### Added

‎docs/cli.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,16 @@ Updates preserve unsupported editor nodes exported as
5555
`python-substack-image:v2` markers. The command refuses to remove or alter
5656
those nodes by default. `--allow-unsupported-change` and
5757
`--allow-image-replacement` are available only with `--yes` for intentional
58-
modifications or unmatched image replacements. The CLI uses its authenticated
59-
API client while rendering, so local images can upload even during a dry run.
58+
modifications or unmatched image replacements.
59+
60+
Draft updates also verify the revision fingerprint exported in
61+
`python-substack-revision:v1` markers. If remote draft changes occurred after
62+
export, the update fails closed to prevent overwriting remote work. Dry-run
63+
updates (`--dry-run --yes`) report specific mismatch details. Markdown files
64+
lacking revision metadata are treated as unprotected updates. Pass
65+
`--allow-conflict --yes` (or `--force --yes`) to explicitly override conflicts
66+
or apply unprotected updates. The CLI uses its authenticated API client while
67+
rendering, so local images can upload even during a dry run.
6068

6169
## Select a publication
6270

‎docs/markdown.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,22 @@ reused, or non-adjacent markers are rejected. Unmatched image replacements requi
219219
`allow_image_replacement=True` or `--allow-image-replacement --yes`. Legacy `v1`
220220
image markers remain supported when the image source resolves to exactly one
221221
remote image.
222+
223+
## Revision fingerprint and conflict prevention
224+
225+
Exported Markdown drafts include a top-level revision marker:
226+
227+
```text
228+
<!-- python-substack-revision:v1 BASE64URL_JSON -->
229+
```
230+
231+
The payload contains the exported `draft_id`, a deterministic SHA-256 body hash,
232+
title, subtitle, audience, comment permissions, section ID, slug, and timestamp.
233+
On draft update, `python-substack` compares the submitted expected revision
234+
against the current remote draft before any write. If remote changes were made
235+
after export, the update fails closed with `DraftConflictError` to prevent silent
236+
data loss. Dry-run updates (`--dry-run --yes`) return actionable mismatch
237+
details. Older Markdown files without revision metadata are treated as
238+
unprotected updates. Conflicted or unprotected updates require explicit
239+
authorization with `allow_conflict=True` (or `force=True`) in Python or
240+
`--allow-conflict --yes` (or `--force --yes`) in the CLI.

‎docs/python-sdk.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,11 @@ default. Removing or changing them raises `ValueError` unless
7373
`allow_unsupported_change=True` is deliberately supplied. Similarly, image
7474
replacements that cannot resolve to their original remote source require
7575
`allow_image_replacement=True`.
76+
77+
The method verifies the revision fingerprint from `python-substack-revision:v1`
78+
markers before updating. If the remote draft changed after export, it raises
79+
`DraftConflictError` unless `allow_conflict=True` (or `force=True`) is passed.
80+
Files lacking revision metadata are treated as unprotected updates and also
81+
require `allow_conflict=True`. In dry-run mode (`dry_run=True`), conflict
82+
status and specific mismatch details are returned in `preview["conflict"]` and
83+
`preview["mismatches"]`.

‎docs/releases/0.10.0.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
layout: default
3+
title: Release 0.10.0
4+
parent: Releases
5+
---
6+
7+
# Release 0.10.0
8+
9+
## Added
10+
11+
- Export a stable remote revision fingerprint in Markdown backup metadata via
12+
`python-substack-revision:v1` markers.
13+
- Conflict-aware draft updates: compare submitted expected revision with current
14+
remote draft before any write, preventing silent overwrites of remote changes.
15+
- Return actionable mismatch details (body changes, title, subtitle, audience,
16+
permissions, section, slug) during dry-run updates (`--dry-run --yes`).
17+
- Fail closed by default on conflicted updates or missing revision metadata
18+
(unprotected updates).
19+
- Add explicit override via `--allow-conflict` / `--force` (requiring `--yes`)
20+
in CLI and `allow_conflict=True` / `force=True` in SDK.
21+
- Export `revision` dictionary metadata in `Api.export_draft_to_markdown` and
22+
CLI `--json drafts export`.
23+
- Introduce `DraftConflictError` exception inheriting from both `ValueError` and
24+
`SubstackException`.

‎docs/safety.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,10 @@ marker and listed in JSON output so it cannot disappear unnoticed. Updates
3535
refuse to remove or alter those markers or replace remote images without
3636
explicit authorization (`--allow-unsupported-change --yes` or
3737
`--allow-image-replacement --yes`).
38+
39+
Draft updates verify remote revision fingerprints before writing. If the
40+
remote draft changed after export, the update fails closed with a conflict
41+
error to prevent overwriting remote work. Markdown files lacking revision
42+
metadata are treated as unprotected updates. Overriding conflicts or applying
43+
unprotected updates requires explicit confirmation (`--allow-conflict --yes`
44+
or `--force --yes` in the CLI, `allow_conflict=True` in the SDK).

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "python-substack"
3-
version = "0.9.1"
3+
version = "0.10.0"
44
description = "Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP."
55
authors = [
66
{ name = "Paolo Mazza", email = "mazzapaolo2019@gmail.com" }

‎substack/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
__author__ = "Paolo Mazza"
44
__email__ = "mazzapaolo2019@gmail.com"
55
__license__ = "MIT License"
6-
__version__ = "0.9.1"
6+
__version__ = "0.10.0"
77
__url__ = "https://github.com/ma2za/python-substack"
88
__download_url__ = "https://pypi.python.org/pypi/python-substack"
99
__description__ = (

‎substack/api.py‎

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,7 @@ def get_draft(self, draft_id):
460460

461461
def export_draft_to_markdown(self, draft_id):
462462
from substack.mdexport import document_to_markdown
463+
from substack.revision import compute_draft_revision, format_revision_marker
463464

464465
draft = self.get_draft(draft_id)
465466
draft_body = draft.get("draft_body")
@@ -474,9 +475,15 @@ def export_draft_to_markdown(self, draft_id):
474475
raise ValueError("Malformed draft body: draft_body must be a JSON object")
475476

476477
markdown, unsupported_nodes = document_to_markdown(draft_body)
478+
revision = compute_draft_revision(draft)
479+
revision_marker = format_revision_marker(revision)
480+
full_markdown = (
481+
f"{revision_marker}\n\n{markdown}" if markdown else f"{revision_marker}\n"
482+
)
477483
return {
478484
"draft": draft,
479-
"markdown": markdown,
485+
"markdown": full_markdown,
486+
"revision": revision,
480487
"unsupported_nodes": unsupported_nodes,
481488
}
482489

@@ -496,13 +503,22 @@ def update_draft_from_markdown(
496503
dry_run: bool = False,
497504
allow_unsupported_change: bool = False,
498505
allow_image_replacement: bool = False,
506+
expected_revision=None,
507+
allow_conflict: bool = False,
508+
force: bool = False,
499509
) -> dict:
500510
"""
501511
Update an existing draft body from Markdown, with optional metadata changes.
502512
"""
503513
from substack import mdrender
514+
from substack.exceptions import DraftConflictError
504515
from substack.mdexport import document_to_markdown
505516
from substack.post import Post
517+
from substack.revision import (
518+
compare_draft_revisions,
519+
compute_draft_revision,
520+
parse_revision_marker,
521+
)
506522

507523
draft = self.get_draft(draft_id)
508524
draft_body = draft.get("draft_body")
@@ -516,6 +532,50 @@ def update_draft_from_markdown(
516532
if not isinstance(draft_body, dict):
517533
raise ValueError("Malformed draft body: draft_body must be a JSON object")
518534

535+
current_rev = compute_draft_revision(draft)
536+
537+
target_expected_rev = None
538+
if expected_revision is not None:
539+
if isinstance(expected_revision, dict):
540+
target_expected_rev = expected_revision
541+
elif isinstance(expected_revision, str):
542+
target_expected_rev = {"revision": expected_revision}
543+
else:
544+
raise ValueError("expected_revision must be a revision dict or string")
545+
else:
546+
target_expected_rev = parse_revision_marker(markdown)
547+
548+
override_enabled = bool(allow_conflict or force)
549+
conflict_detected = False
550+
mismatches = []
551+
552+
if target_expected_rev is None:
553+
conflict_detected = True
554+
mismatches = [
555+
"Markdown file is missing revision metadata (unprotected update); safe conflict comparison is impossible."
556+
]
557+
if not override_enabled and not dry_run:
558+
raise DraftConflictError(
559+
"Refusing to update: Markdown file is missing revision metadata (unprotected update). "
560+
"Export the draft first or pass allow_conflict=True to proceed.",
561+
mismatches=mismatches,
562+
expected_revision=None,
563+
current_revision=current_rev,
564+
)
565+
else:
566+
mismatches = compare_draft_revisions(target_expected_rev, current_rev)
567+
if mismatches:
568+
conflict_detected = True
569+
if not override_enabled and not dry_run:
570+
details = "; ".join(mismatches)
571+
raise DraftConflictError(
572+
f"Refusing to update: remote draft has changed since export ({details}). "
573+
"Re-export the draft or pass allow_conflict=True to proceed.",
574+
mismatches=mismatches,
575+
expected_revision=target_expected_rev,
576+
current_revision=current_rev,
577+
)
578+
519579
_, remote_unsupported = document_to_markdown(draft_body)
520580

521581
image_markers = []
@@ -657,7 +717,7 @@ def update_draft_from_markdown(
657717
if tags_list:
658718
tags_result = self.add_tags_to_post(draft_id, tags_list)
659719

660-
return {
720+
result_payload = {
661721
"action": "update",
662722
"draft_id": draft_id,
663723
"dry_run": dry_run,
@@ -666,7 +726,14 @@ def update_draft_from_markdown(
666726
"draft": updated_draft,
667727
"tags": tags_result,
668728
"unsupported_nodes": unsupported_nodes,
729+
"conflict": conflict_detected,
730+
"mismatches": mismatches,
731+
"expected_revision": target_expected_rev,
732+
"current_revision": current_rev,
669733
}
734+
if override_enabled and conflict_detected:
735+
result_payload["override"] = True
736+
return result_payload
670737

671738
def delete_draft(self, draft_id):
672739
"""

‎substack/cli.py‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
from dotenv import load_dotenv
1111

1212
from substack import Api, __version__
13-
from substack.exceptions import SubstackAPIException, SubstackRequestException
13+
from substack.exceptions import (
14+
DraftConflictError,
15+
SubstackAPIException,
16+
SubstackRequestException,
17+
)
1418
from substack.post import Post
1519

1620

@@ -146,6 +150,14 @@ def _redact(message):
146150

147151

148152
def _error_payload(exc):
153+
if isinstance(exc, DraftConflictError):
154+
return {
155+
"error": {
156+
"type": "conflict_error",
157+
"message": _redact(exc.message),
158+
"mismatches": exc.mismatches,
159+
}
160+
}
149161
if isinstance(exc, SubstackAPIException):
150162
return {
151163
"error": {
@@ -314,6 +326,8 @@ def _drafts_update(api, args):
314326
raise CLIUsageError("--yes is required when using --allow-unsupported-change")
315327
if args.allow_image_replacement and not args.yes:
316328
raise CLIUsageError("--yes is required when using --allow-image-replacement")
329+
if args.allow_conflict and not args.yes:
330+
raise CLIUsageError("--yes is required when using --allow-conflict")
317331

318332
if not args.yes and (args.json_output or not sys.stdin.isatty()):
319333
raise CLIUsageError("--yes is required in non-interactive or JSON mode")
@@ -354,13 +368,25 @@ def _drafts_update(api, args):
354368
dry_run=args.dry_run,
355369
allow_unsupported_change=args.allow_unsupported_change,
356370
allow_image_replacement=args.allow_image_replacement,
371+
allow_conflict=args.allow_conflict,
357372
)
358373

359374
if args.json_output:
360375
_print_json(result)
361376
else:
362377
status = "Dry-run updated" if args.dry_run else "Updated"
363-
print(f"{status} draft {args.draft_id}")
378+
if result.get("conflict"):
379+
print(f"Conflict detected in draft {args.draft_id}:")
380+
for mismatch in result.get("mismatches", []):
381+
print(f" - {mismatch}")
382+
if result.get("dry_run"):
383+
print(
384+
f"Dry-run preview completed for draft {args.draft_id} with conflicts."
385+
)
386+
else:
387+
print(f"{status} draft {args.draft_id} (override applied)")
388+
else:
389+
print(f"{status} draft {args.draft_id}")
364390

365391

366392
def _drafts_export(api, args):
@@ -381,6 +407,8 @@ def _drafts_export(api, args):
381407
"markdown": markdown,
382408
"unsupported_nodes": result["unsupported_nodes"],
383409
}
410+
if "revision" in result and result["revision"] is not None:
411+
payload["revision"] = result["revision"]
384412
if args.json_output:
385413
_print_json(payload)
386414
elif output_path is not None:
@@ -517,6 +545,13 @@ def _build_parser():
517545
drafts_update.add_argument("--dry-run", action="store_true")
518546
drafts_update.add_argument("--allow-unsupported-change", action="store_true")
519547
drafts_update.add_argument("--allow-image-replacement", action="store_true")
548+
drafts_update.add_argument(
549+
"--allow-conflict",
550+
"--force",
551+
action="store_true",
552+
dest="allow_conflict",
553+
help="Allow updating draft even if remote revision has changed or revision metadata is missing.",
554+
)
520555
drafts_update.add_argument("--yes", action="store_true")
521556
drafts_update.set_defaults(handler=_drafts_update)
522557

0 commit comments

Comments
 (0)