python-substack 0.9.0__py3-none-any.whl → 0.10.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.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-substack
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP.
5
- License: MIT
5
+ License-Expression: MIT
6
6
  License-File: LICENSE
7
7
  Keywords: substack,substack-api,cli,newsletter,publishing,automation,mcp
8
8
  Author: Paolo Mazza
@@ -10,13 +10,11 @@ Author-email: mazzapaolo2019@gmail.com
10
10
  Requires-Python: >=3.10,<4.0
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
13
  Classifier: Programming Language :: Python :: 3
15
14
  Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
19
- Classifier: Programming Language :: Python :: 3.14
20
18
  Classifier: Topic :: Communications :: Email
21
19
  Classifier: Topic :: Internet :: WWW/HTTP
22
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
@@ -0,0 +1,15 @@
1
+ substack/__init__.py,sha256=1z7cdQwJPM4msiHdIoSzm4etUIu5Jcc5CKmFDHcnODA,451
2
+ substack/api.py,sha256=KWEJXjlxZY1sQ9j5qZSGxHfaATyE3O9Nup_elsXBHNU,37927
3
+ substack/cli.py,sha256=dgUTgZeX4sgJDXsFmPIJRBpNGgGQyLR2OpXoEcoHQa0,27586
4
+ substack/exceptions.py,sha256=xuckEoS-PYGYt-9zvuG8CDbr4WncpuKqGXN34yBvGnE,2140
5
+ substack/mdexport.py,sha256=Tzkav3mnF9eIUVHUqUzEQkOTiJsLYde1Xi2AcE_cUdk,14017
6
+ substack/mdrender.py,sha256=xRJIO4_6gcQ9jwLFm32KByi7bRmOJyowqF41GmYCA7A,15450
7
+ substack/nodes.py,sha256=fTsGO0-lTztcXiloIjXrHVnJes9EcltGcrX-mEV2ceQ,5004
8
+ substack/post.py,sha256=qDXu-xzO3nRXtahc-yG2EhxKAXB2hG_Z1nLa55UMBGg,19697
9
+ substack/revision.py,sha256=guEJ8vd38eqHHDqwHL0baf3v02U7RO34Li3D2pvCka8,8796
10
+ substack_mcp/mcp_server.py,sha256=M5XH3C4iWjzvCw3uOPbY2NFljTQAl8FLt14RKaTzka0,11324
11
+ python_substack-0.10.0.dist-info/METADATA,sha256=fgjfdA7h0iQv_kCqxFLFw3Gemhd-LHeJOKhhEZFxthc,9209
12
+ python_substack-0.10.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
13
+ python_substack-0.10.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
14
+ python_substack-0.10.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
15
+ python_substack-0.10.0.dist-info/RECORD,,
substack/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  __author__ = "Paolo Mazza"
4
4
  __email__ = "mazzapaolo2019@gmail.com"
5
5
  __license__ = "MIT License"
6
- __version__ = "0.9.0"
6
+ __version__ = "0.10.0"
7
7
  __url__ = "https://github.com/ma2za/python-substack"
8
8
  __download_url__ = "https://pypi.python.org/pypi/python-substack"
9
9
  __description__ = (
substack/api.py CHANGED
@@ -460,6 +460,7 @@ class Api:
460
460
 
461
461
  def export_draft_to_markdown(self, draft_id):
462
462
  from substack.mdexport import document_to_markdown
463
+ from substack.revision import compute_draft_revision, format_revision_marker
463
464
 
464
465
  draft = self.get_draft(draft_id)
465
466
  draft_body = draft.get("draft_body")
@@ -474,9 +475,15 @@ class Api:
474
475
  raise ValueError("Malformed draft body: draft_body must be a JSON object")
475
476
 
476
477
  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
+ )
477
483
  return {
478
484
  "draft": draft,
479
- "markdown": markdown,
485
+ "markdown": full_markdown,
486
+ "revision": revision,
480
487
  "unsupported_nodes": unsupported_nodes,
481
488
  }
482
489
 
@@ -496,12 +503,22 @@ class Api:
496
503
  dry_run: bool = False,
497
504
  allow_unsupported_change: bool = False,
498
505
  allow_image_replacement: bool = False,
506
+ expected_revision=None,
507
+ allow_conflict: bool = False,
508
+ force: bool = False,
499
509
  ) -> dict:
500
510
  """
501
511
  Update an existing draft body from Markdown, with optional metadata changes.
502
512
  """
513
+ from substack import mdrender
514
+ from substack.exceptions import DraftConflictError
503
515
  from substack.mdexport import document_to_markdown
504
516
  from substack.post import Post
517
+ from substack.revision import (
518
+ compare_draft_revisions,
519
+ compute_draft_revision,
520
+ parse_revision_marker,
521
+ )
505
522
 
506
523
  draft = self.get_draft(draft_id)
507
524
  draft_body = draft.get("draft_body")
@@ -515,8 +532,53 @@ class Api:
515
532
  if not isinstance(draft_body, dict):
516
533
  raise ValueError("Malformed draft body: draft_body must be a JSON object")
517
534
 
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
+
518
579
  _, remote_unsupported = document_to_markdown(draft_body)
519
580
 
581
+ image_markers = []
520
582
  post = Post(
521
583
  title=draft.get("title", ""),
522
584
  subtitle=(
@@ -526,30 +588,88 @@ class Api:
526
588
  audience=audience,
527
589
  write_comment_permissions=write_comment_permissions,
528
590
  )
529
- post.from_markdown(markdown, api=self)
591
+ rendered = mdrender.markdown_to_doc(
592
+ markdown, api=self, track_markers=image_markers
593
+ )
594
+ post.draft_body["content"] = rendered
530
595
 
531
596
  remote_images = _image_attrs(draft_body)
532
597
  submitted_images = _image_attrs(post.draft_body)
533
- for submitted in submitted_images:
534
- matches = [
535
- image
536
- for image in remote_images
537
- if image.get("src") == submitted.get("src")
538
- ]
539
- if len(matches) == 1:
598
+
599
+ consumed_remote_indices = set()
600
+
601
+ for submitted, marker in zip(submitted_images, image_markers):
602
+ if marker is not None and marker.get("version") == 2:
603
+ bound_src = marker["src"]
604
+ matched_idx = None
605
+ for idx, r_img in enumerate(remote_images):
606
+ if (
607
+ idx not in consumed_remote_indices
608
+ and r_img.get("src") == bound_src
609
+ ):
610
+ matched_idx = idx
611
+ break
612
+
613
+ if matched_idx is None:
614
+ all_with_src = [
615
+ idx
616
+ for idx, r_img in enumerate(remote_images)
617
+ if r_img.get("src") == bound_src
618
+ ]
619
+ if not all_with_src:
620
+ raise ValueError(
621
+ f"Refusing to update: stale image marker bound to '{bound_src}'."
622
+ )
623
+ else:
624
+ raise ValueError(
625
+ f"Refusing to update: reused or duplicate image marker for '{bound_src}'."
626
+ )
627
+
628
+ consumed_remote_indices.add(matched_idx)
629
+ remote_img = remote_images[matched_idx]
540
630
  source, alt, href = (
541
631
  submitted.get("src"),
542
632
  submitted.get("alt"),
543
633
  submitted.get("href"),
544
634
  )
545
- submitted.update(matches[0])
635
+ submitted.update(remote_img)
636
+ submitted.update(marker["attrs"])
546
637
  submitted.update(
547
638
  {"src": source, "alt": alt, "href": href, "isProcessing": False}
548
639
  )
549
- elif not allow_image_replacement:
550
- raise ValueError(
551
- "Refusing to update: image cannot be matched uniquely; set allow_image_replacement=True to proceed."
552
- )
640
+
641
+ elif marker is not None and marker.get("version") == 1:
642
+ sub_src = submitted.get("src")
643
+ matches = [
644
+ idx
645
+ for idx, r_img in enumerate(remote_images)
646
+ if r_img.get("src") == sub_src
647
+ and idx not in consumed_remote_indices
648
+ ]
649
+ if len(matches) == 1:
650
+ matched_idx = matches[0]
651
+ consumed_remote_indices.add(matched_idx)
652
+ remote_img = remote_images[matched_idx]
653
+ source, alt, href = (
654
+ submitted.get("src"),
655
+ submitted.get("alt"),
656
+ submitted.get("href"),
657
+ )
658
+ submitted.update(remote_img)
659
+ submitted.update(marker["attrs"])
660
+ submitted.update(
661
+ {"src": source, "alt": alt, "href": href, "isProcessing": False}
662
+ )
663
+ elif not allow_image_replacement:
664
+ raise ValueError(
665
+ "Refusing to update: image cannot be matched uniquely; set allow_image_replacement=True to proceed."
666
+ )
667
+
668
+ else:
669
+ if not allow_image_replacement:
670
+ raise ValueError(
671
+ "Refusing to update: image cannot be matched uniquely; set allow_image_replacement=True to proceed."
672
+ )
553
673
 
554
674
  _, submitted_unsupported = document_to_markdown(post.draft_body)
555
675
 
@@ -597,7 +717,7 @@ class Api:
597
717
  if tags_list:
598
718
  tags_result = self.add_tags_to_post(draft_id, tags_list)
599
719
 
600
- return {
720
+ result_payload = {
601
721
  "action": "update",
602
722
  "draft_id": draft_id,
603
723
  "dry_run": dry_run,
@@ -606,7 +726,14 @@ class Api:
606
726
  "draft": updated_draft,
607
727
  "tags": tags_result,
608
728
  "unsupported_nodes": unsupported_nodes,
729
+ "conflict": conflict_detected,
730
+ "mismatches": mismatches,
731
+ "expected_revision": target_expected_rev,
732
+ "current_revision": current_rev,
609
733
  }
734
+ if override_enabled and conflict_detected:
735
+ result_payload["override"] = True
736
+ return result_payload
610
737
 
611
738
  def delete_draft(self, draft_id):
612
739
  """
substack/cli.py CHANGED
@@ -10,7 +10,11 @@ import yaml
10
10
  from dotenv import load_dotenv
11
11
 
12
12
  from substack import Api, __version__
13
- from substack.exceptions import SubstackAPIException, SubstackRequestException
13
+ from substack.exceptions import (
14
+ DraftConflictError,
15
+ SubstackAPIException,
16
+ SubstackRequestException,
17
+ )
14
18
  from substack.post import Post
15
19
 
16
20
 
@@ -146,6 +150,14 @@ def _redact(message):
146
150
 
147
151
 
148
152
  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
+ }
149
161
  if isinstance(exc, SubstackAPIException):
150
162
  return {
151
163
  "error": {
@@ -314,6 +326,8 @@ def _drafts_update(api, args):
314
326
  raise CLIUsageError("--yes is required when using --allow-unsupported-change")
315
327
  if args.allow_image_replacement and not args.yes:
316
328
  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")
317
331
 
318
332
  if not args.yes and (args.json_output or not sys.stdin.isatty()):
319
333
  raise CLIUsageError("--yes is required in non-interactive or JSON mode")
@@ -354,13 +368,25 @@ def _drafts_update(api, args):
354
368
  dry_run=args.dry_run,
355
369
  allow_unsupported_change=args.allow_unsupported_change,
356
370
  allow_image_replacement=args.allow_image_replacement,
371
+ allow_conflict=args.allow_conflict,
357
372
  )
358
373
 
359
374
  if args.json_output:
360
375
  _print_json(result)
361
376
  else:
362
377
  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}")
364
390
 
365
391
 
366
392
  def _drafts_export(api, args):
@@ -381,6 +407,8 @@ def _drafts_export(api, args):
381
407
  "markdown": markdown,
382
408
  "unsupported_nodes": result["unsupported_nodes"],
383
409
  }
410
+ if "revision" in result and result["revision"] is not None:
411
+ payload["revision"] = result["revision"]
384
412
  if args.json_output:
385
413
  _print_json(payload)
386
414
  elif output_path is not None:
@@ -517,6 +545,13 @@ def _build_parser():
517
545
  drafts_update.add_argument("--dry-run", action="store_true")
518
546
  drafts_update.add_argument("--allow-unsupported-change", action="store_true")
519
547
  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
+ )
520
555
  drafts_update.add_argument("--yes", action="store_true")
521
556
  drafts_update.set_defaults(handler=_drafts_update)
522
557
 
substack/exceptions.py CHANGED
@@ -12,7 +12,13 @@ def _redact_message(text: str) -> str:
12
12
  return text
13
13
 
14
14
 
15
- class SubstackAPIException(Exception):
15
+ class SubstackException(Exception):
16
+ """Base exception for all python-substack exceptions."""
17
+
18
+ pass
19
+
20
+
21
+ class SubstackAPIException(SubstackException):
16
22
  def __init__(self, status_code, text):
17
23
  text = _redact_message(text)
18
24
  try:
@@ -32,7 +38,7 @@ class SubstackAPIException(Exception):
32
38
  return f"APIError(code={self.status_code}): {self.message}"
33
39
 
34
40
 
35
- class SubstackRequestException(Exception):
41
+ class SubstackRequestException(SubstackException):
36
42
  def __init__(self, message):
37
43
  self.message = _redact_message(message)
38
44
 
@@ -42,3 +48,23 @@ class SubstackRequestException(Exception):
42
48
 
43
49
  class SectionNotExistsException(SubstackRequestException):
44
50
  pass
51
+
52
+
53
+ class DraftConflictError(ValueError, SubstackException):
54
+ """Raised when an update conflicts with the current remote draft state or lacks revision metadata."""
55
+
56
+ def __init__(
57
+ self,
58
+ message: str,
59
+ mismatches: list = None,
60
+ expected_revision: dict = None,
61
+ current_revision: dict = None,
62
+ ):
63
+ super().__init__(message)
64
+ self.message = message
65
+ self.mismatches = mismatches or []
66
+ self.expected_revision = expected_revision
67
+ self.current_revision = current_revision
68
+
69
+ def __str__(self):
70
+ return self.message
substack/mdexport.py CHANGED
@@ -58,18 +58,19 @@ def _marker(node, unsupported):
58
58
 
59
59
 
60
60
  def _image_marker(attrs):
61
- preserved = {
62
- key: value
63
- for key, value in attrs.items()
64
- if key not in {"src", "alt", "href", "isProcessing"}
61
+ payload_data = {
62
+ "src": attrs["src"],
63
+ **{
64
+ key: value
65
+ for key, value in attrs.items()
66
+ if key not in {"src", "alt", "href", "isProcessing"}
67
+ },
65
68
  }
66
- if not preserved:
67
- return ""
68
69
  payload = json.dumps(
69
- preserved, ensure_ascii=False, separators=(",", ":"), sort_keys=True
70
+ payload_data, ensure_ascii=False, separators=(",", ":"), sort_keys=True
70
71
  ).encode("utf-8")
71
72
  encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
72
- return f"\n<!-- python-substack-image:v1 {encoded} -->"
73
+ return f"\n<!-- python-substack-image:v2 {encoded} -->"
73
74
 
74
75
 
75
76
  def _require_dict(node, context):
@@ -229,7 +230,6 @@ def _render_image(node, unsupported):
229
230
  image["type"] != "image2"
230
231
  or _has_unknown_keys(node, {"type", "content"})
231
232
  or _has_unknown_keys(image, {"type", "attrs"})
232
- or _has_unknown_keys(attrs, _IMAGE_ATTRS)
233
233
  or not isinstance(attrs.get("src"), str)
234
234
  ):
235
235
  return _marker(node, unsupported)
substack/mdrender.py CHANGED
@@ -45,7 +45,7 @@ _MARK_FOR = {
45
45
  }
46
46
 
47
47
 
48
- def parse_node_marker(comment_content: str) -> dict | None:
48
+ def parse_node_marker(comment_content: str) -> dict | None:
49
49
  """
50
50
  Parse a python-substack-node:v1 comment marker and return the parsed JSON dictionary.
51
51
 
@@ -88,29 +88,45 @@ def parse_node_marker(comment_content: str) -> dict | None:
88
88
  if not isinstance(node_type, str) or not node_type:
89
89
  raise ValueError("Marker payload has missing or empty 'type'")
90
90
 
91
- return data
92
-
93
-
94
- def parse_image_marker(comment_content: str) -> dict | None:
95
- clean = comment_content.strip()
96
- match = re.match(
97
- r"^<!--\s*python-substack-image:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean
98
- )
99
- if not match:
100
- if "python-substack-image:v1" in clean:
101
- raise ValueError("Corrupt image marker format")
102
- return None
103
- encoded = match.group(1)
104
- encoded += "=" * (-len(encoded) % 4)
105
- try:
106
- attrs = json.loads(base64.urlsafe_b64decode(encoded.encode("ascii")))
107
- except Exception as exc:
108
- raise ValueError("Invalid image marker") from exc
109
- if not isinstance(attrs, dict) or any(
110
- key in {"src", "alt", "href", "isProcessing"} for key in attrs
111
- ):
112
- raise ValueError("Invalid image marker attributes")
113
- return attrs
91
+ return data
92
+
93
+
94
+ def parse_image_marker(comment_content: str) -> dict | None:
95
+ clean = comment_content.strip()
96
+ match = re.match(
97
+ r"^<!--\s*python-substack-image:(v1|v2)\s+([A-Za-z0-9_-]+=*)\s*-->$", clean
98
+ )
99
+ if not match:
100
+ if "python-substack-image:" in clean:
101
+ raise ValueError("Corrupt image marker format")
102
+ return None
103
+ version_str, encoded = match.group(1), match.group(2)
104
+ encoded += "=" * (-len(encoded) % 4)
105
+ try:
106
+ decoded_bytes = base64.urlsafe_b64decode(encoded.encode("ascii"))
107
+ except Exception as exc:
108
+ raise ValueError("Invalid image marker") from exc
109
+ try:
110
+ attrs = json.loads(decoded_bytes.decode("utf-8"))
111
+ except Exception as exc:
112
+ raise ValueError("Invalid image marker") from exc
113
+ if not isinstance(attrs, dict):
114
+ raise ValueError("Invalid image marker attributes")
115
+
116
+ if version_str == "v1":
117
+ if any(key in {"src", "alt", "href", "isProcessing"} for key in attrs):
118
+ raise ValueError("Invalid image marker attributes")
119
+ return {"version": 1, "src": None, "attrs": attrs}
120
+
121
+ if version_str == "v2":
122
+ if "src" not in attrs or not isinstance(attrs["src"], str):
123
+ raise ValueError("Invalid image marker attributes")
124
+ if any(key in {"alt", "href", "isProcessing"} for key in attrs):
125
+ raise ValueError("Invalid image marker attributes")
126
+ preserved = {k: v for k, v in attrs.items() if k != "src"}
127
+ return {"version": 2, "src": attrs["src"], "attrs": preserved}
128
+
129
+ return None
114
130
 
115
131
 
116
132
  def _make_parser() -> MarkdownIt:
@@ -177,6 +193,8 @@ def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[D
177
193
  if alt:
178
194
  out.append(nodes.text(alt, marks))
179
195
  elif t == "html_inline":
196
+ if "python-substack-image:" in child.content:
197
+ raise ValueError("Image marker must immediately follow an image")
180
198
  marker_data = parse_node_marker(child.content)
181
199
  if marker_data is not None:
182
200
  out.append(marker_data)
@@ -325,7 +343,9 @@ def _footnote_definitions(tree: SyntaxTreeNode, api) -> Dict[int, List[Dict]]:
325
343
  return definitions
326
344
 
327
345
 
328
- def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]:
346
+ def markdown_to_doc(
347
+ markdown_content: str, api=None, track_markers: Optional[List] = None
348
+ ) -> List[Dict]:
329
349
  """Convert Markdown into a list of Substack ProseMirror block nodes."""
330
350
  tree = SyntaxTreeNode(_make_parser().parse(markdown_content))
331
351
 
@@ -333,18 +353,39 @@ def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]:
333
353
 
334
354
  ctx: Dict = {"order": []}
335
355
  out: List[Dict] = []
336
- for node in tree.children:
337
- if node.type == "footnote_block":
338
- continue
339
- if node.type == "html_block":
340
- attrs = parse_image_marker(node.content)
341
- if attrs is not None:
342
- if not out or out[-1].get("type") != "captionedImage":
343
- raise ValueError("Image marker must immediately follow an image")
344
- out[-1]["content"][0]["attrs"].update(attrs)
345
- out[-1]["content"][0]["attrs"]["isProcessing"] = False
346
- continue
347
- out.extend(_render_block(node, api, ctx))
356
+ last_was_image = False
357
+ last_image_idx: Optional[int] = None
358
+
359
+ for node in tree.children:
360
+ if node.type == "footnote_block":
361
+ last_was_image = False
362
+ continue
363
+ if node.type == "html_block":
364
+ marker = parse_image_marker(node.content)
365
+ if marker is not None:
366
+ if not last_was_image or last_image_idx is None:
367
+ raise ValueError("Image marker must immediately follow an image")
368
+ last_was_image = False
369
+ out[last_image_idx]["content"][0]["attrs"].update(marker["attrs"])
370
+ out[last_image_idx]["content"][0]["attrs"]["isProcessing"] = False
371
+ if track_markers is not None:
372
+ track_markers[-1] = marker
373
+ continue
374
+ last_was_image = False
375
+
376
+ rendered = _render_block(node, api, ctx)
377
+ if rendered:
378
+ for blk in rendered:
379
+ out.append(blk)
380
+ if blk.get("type") == NodeType.CAPTIONED_IMAGE:
381
+ last_was_image = True
382
+ last_image_idx = len(out) - 1
383
+ if track_markers is not None:
384
+ track_markers.append(None)
385
+ else:
386
+ last_was_image = False
387
+ else:
388
+ last_was_image = False
348
389
 
349
390
  # Emit one footnote block per reference, in anchor order, numbered to match.
350
391
  for number, footnote_id in enumerate(ctx["order"], start=1):
substack/revision.py ADDED
@@ -0,0 +1,254 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import re
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from markdown_it import MarkdownIt
8
+
9
+
10
+ def compute_draft_revision(draft: Dict[str, Any]) -> Dict[str, Any]:
11
+ """Compute deterministic revision and fingerprint metadata from a Substack draft dict."""
12
+ draft_id = draft.get("id")
13
+ title = (
14
+ draft.get("draft_title") if "draft_title" in draft else draft.get("title", "")
15
+ )
16
+ subtitle = (
17
+ draft.get("draft_subtitle")
18
+ if "draft_subtitle" in draft
19
+ else draft.get("subtitle", "")
20
+ )
21
+ audience = draft.get("audience") or "everyone"
22
+ write_comment_permissions = draft.get("write_comment_permissions") or "everyone"
23
+ draft_section_id = (
24
+ draft.get("draft_section_id")
25
+ if "draft_section_id" in draft
26
+ else draft.get("section_id")
27
+ )
28
+ slug = draft.get("slug") or ""
29
+ updated_at = draft.get("draft_updated_at") or draft.get("updated_at")
30
+
31
+ raw_body = draft.get("draft_body")
32
+ if isinstance(raw_body, str):
33
+ try:
34
+ body_obj = json.loads(raw_body)
35
+ except Exception:
36
+ body_obj = raw_body
37
+ else:
38
+ body_obj = raw_body
39
+
40
+ if isinstance(body_obj, (dict, list)):
41
+ body_canonical = json.dumps(
42
+ body_obj, ensure_ascii=False, separators=(",", ":"), sort_keys=True
43
+ )
44
+ else:
45
+ body_canonical = str(body_obj or "")
46
+
47
+ body_hash = hashlib.sha256(body_canonical.encode("utf-8")).hexdigest()
48
+
49
+ fingerprint_components = {
50
+ "draft_id": draft_id,
51
+ "body_hash": body_hash,
52
+ "title": title or "",
53
+ "subtitle": subtitle or "",
54
+ "audience": audience,
55
+ "write_comment_permissions": write_comment_permissions,
56
+ "draft_section_id": draft_section_id,
57
+ "slug": slug,
58
+ }
59
+ fingerprint_bytes = json.dumps(
60
+ fingerprint_components,
61
+ ensure_ascii=False,
62
+ separators=(",", ":"),
63
+ sort_keys=True,
64
+ ).encode("utf-8")
65
+ revision_hash = hashlib.sha256(fingerprint_bytes).hexdigest()
66
+
67
+ return {
68
+ "version": 1,
69
+ "draft_id": draft_id,
70
+ "revision": revision_hash,
71
+ "body_hash": body_hash,
72
+ "title": title or "",
73
+ "subtitle": subtitle or "",
74
+ "audience": audience,
75
+ "write_comment_permissions": write_comment_permissions,
76
+ "draft_section_id": draft_section_id,
77
+ "slug": slug,
78
+ "updated_at": str(updated_at) if updated_at else None,
79
+ }
80
+
81
+
82
+ def format_revision_marker(revision_info: Dict[str, Any]) -> str:
83
+ """Format revision metadata as a versioned HTML comment marker."""
84
+ payload = json.dumps(
85
+ revision_info, ensure_ascii=False, separators=(",", ":"), sort_keys=True
86
+ ).encode("utf-8")
87
+ encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
88
+ return f"<!-- python-substack-revision:v1 {encoded} -->"
89
+
90
+
91
+ def parse_revision_marker(markdown: str) -> Optional[Dict[str, Any]]:
92
+ """
93
+ Parse a python-substack-revision:v1 comment marker from markdown content.
94
+
95
+ Only top-level HTML comment blocks are considered; comments inside code blocks
96
+ are ignored.
97
+
98
+ Returns the parsed revision dictionary, or None if no marker is found.
99
+ Raises ValueError if an attempted marker is corrupt, malformed, unsupported, or duplicated.
100
+ """
101
+ md = MarkdownIt()
102
+ tokens = md.parse(markdown)
103
+
104
+ found_markers = []
105
+ marker_pattern = re.compile(
106
+ r"^<!--\s*python-substack-revision:v([0-9]+)\s+([A-Za-z0-9_-]+=*)\s*-->$"
107
+ )
108
+
109
+ for token in tokens:
110
+ if token.type == "html_block":
111
+ clean = token.content.strip()
112
+ match = marker_pattern.match(clean)
113
+ if match:
114
+ found_markers.append(match.groups())
115
+ elif "python-substack-revision:" in clean:
116
+ raise ValueError(
117
+ "Malformed revision marker: corrupt comment format or invalid characters"
118
+ )
119
+
120
+ if not found_markers:
121
+ return None
122
+
123
+ if len(found_markers) > 1:
124
+ raise ValueError("Malformed revision marker: multiple revision markers found")
125
+
126
+ version_str, encoded = found_markers[0]
127
+ if version_str != "1":
128
+ raise ValueError(
129
+ f"Malformed revision marker: unsupported version '{version_str}'"
130
+ )
131
+
132
+ pad = len(encoded) % 4
133
+ if pad:
134
+ encoded += "=" * (4 - pad)
135
+
136
+ try:
137
+ raw = base64.urlsafe_b64decode(encoded.encode("ascii"))
138
+ except Exception as exc:
139
+ raise ValueError("Malformed revision marker: invalid base64 encoding") from exc
140
+
141
+ try:
142
+ data = json.loads(raw.decode("utf-8"))
143
+ except Exception as exc:
144
+ raise ValueError("Malformed revision marker: invalid JSON payload") from exc
145
+
146
+ if not isinstance(data, dict):
147
+ raise ValueError("Malformed revision marker: payload must be a JSON object")
148
+
149
+ if not data.get("revision") or not isinstance(data.get("revision"), str):
150
+ raise ValueError(
151
+ "Malformed revision marker: missing or empty required 'revision' field"
152
+ )
153
+
154
+ return data
155
+
156
+
157
+ def compare_draft_revisions(
158
+ expected: Dict[str, Any], current: Dict[str, Any]
159
+ ) -> List[str]:
160
+ """
161
+ Compare expected revision metadata with current remote draft revision metadata.
162
+
163
+ Returns a list of human-readable, actionable mismatch descriptions.
164
+ If the list is empty, there is no conflict.
165
+ """
166
+ mismatches: List[str] = []
167
+
168
+ if (
169
+ expected.get("draft_id") is not None
170
+ and current.get("draft_id") is not None
171
+ and str(expected.get("draft_id")) != str(current.get("draft_id"))
172
+ ):
173
+ mismatches.append(
174
+ f"Draft ID mismatch: expected {expected.get('draft_id')}, got {current.get('draft_id')}"
175
+ )
176
+
177
+ if (
178
+ expected.get("body_hash")
179
+ and current.get("body_hash")
180
+ and expected.get("body_hash") != current.get("body_hash")
181
+ ):
182
+ mismatches.append("Remote draft body has changed since export")
183
+
184
+ if (
185
+ expected.get("title") is not None
186
+ and current.get("title") is not None
187
+ and expected.get("title") != current.get("title")
188
+ ):
189
+ mismatches.append(
190
+ f"Remote title changed: expected '{expected.get('title')}', got '{current.get('title')}'"
191
+ )
192
+
193
+ if (
194
+ expected.get("subtitle") is not None
195
+ and current.get("subtitle") is not None
196
+ and expected.get("subtitle") != current.get("subtitle")
197
+ ):
198
+ mismatches.append(
199
+ f"Remote subtitle changed: expected '{expected.get('subtitle')}', got '{current.get('subtitle')}'"
200
+ )
201
+
202
+ if (
203
+ expected.get("audience")
204
+ and current.get("audience")
205
+ and expected.get("audience") != current.get("audience")
206
+ ):
207
+ mismatches.append(
208
+ f"Remote audience changed: expected '{expected.get('audience')}', got '{current.get('audience')}'"
209
+ )
210
+
211
+ if (
212
+ expected.get("write_comment_permissions")
213
+ and current.get("write_comment_permissions")
214
+ and expected.get("write_comment_permissions")
215
+ != current.get("write_comment_permissions")
216
+ ):
217
+ mismatches.append(
218
+ f"Remote write_comment_permissions changed: expected '{expected.get('write_comment_permissions')}', got '{current.get('write_comment_permissions')}'"
219
+ )
220
+
221
+ if expected.get("draft_section_id") != current.get("draft_section_id"):
222
+ mismatches.append(
223
+ f"Remote section changed: expected {expected.get('draft_section_id')}, got {current.get('draft_section_id')}"
224
+ )
225
+
226
+ if (
227
+ expected.get("slug") is not None
228
+ and current.get("slug") is not None
229
+ and expected.get("slug") != current.get("slug")
230
+ ):
231
+ mismatches.append(
232
+ f"Remote slug changed: expected '{expected.get('slug')}', got '{current.get('slug')}'"
233
+ )
234
+
235
+ if (
236
+ expected.get("updated_at")
237
+ and current.get("updated_at")
238
+ and expected.get("updated_at") != current.get("updated_at")
239
+ ):
240
+ mismatches.append(
241
+ f"Remote update timestamp changed: expected '{expected.get('updated_at')}', got '{current.get('updated_at')}'"
242
+ )
243
+
244
+ if (
245
+ expected.get("revision")
246
+ and current.get("revision")
247
+ and expected.get("revision") != current.get("revision")
248
+ and not mismatches
249
+ ):
250
+ mismatches.append(
251
+ f"Remote revision fingerprint mismatch: expected '{expected.get('revision')}', got '{current.get('revision')}'"
252
+ )
253
+
254
+ return mismatches
@@ -1,14 +0,0 @@
1
- substack/__init__.py,sha256=W0VG-klGuBHRwwQxQWEdvxB68z6YVQMvEAWO3yNn8bY,449
2
- substack/api.py,sha256=k6ympBEJnwE3Gy0n2qGYTM4d26As1--SZTgZQWl7PzU,32176
3
- substack/cli.py,sha256=56BTWxfr3gpAaJOLJsKRG7k6PPT7xSgLdp632H4mezA,26292
4
- substack/exceptions.py,sha256=Y61XSPyxDxXPbVlioFvKhb94MRY0tpD389F5YdL1rGM,1390
5
- substack/mdexport.py,sha256=lg2DCTHWRa_w7O2eO6ks1Iif2A7_tphDIayI0w_yxKk,14037
6
- substack/mdrender.py,sha256=Bq0VXs0B3cC2fpKzyRNxApM-ZbK_-pC9wPvn1HIanHw,13666
7
- substack/nodes.py,sha256=fTsGO0-lTztcXiloIjXrHVnJes9EcltGcrX-mEV2ceQ,5004
8
- substack/post.py,sha256=qDXu-xzO3nRXtahc-yG2EhxKAXB2hG_Z1nLa55UMBGg,19697
9
- substack_mcp/mcp_server.py,sha256=M5XH3C4iWjzvCw3uOPbY2NFljTQAl8FLt14RKaTzka0,11324
10
- python_substack-0.9.0.dist-info/METADATA,sha256=1S7eMKYmbI8AMKG5rruRyRcUWESxELT6XStsQQeRCsY,9299
11
- python_substack-0.9.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
12
- python_substack-0.9.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
13
- python_substack-0.9.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
14
- python_substack-0.9.0.dist-info/RECORD,,