python-substack 0.9.0__py3-none-any.whl → 0.9.1__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.9.1
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,14 @@
1
+ substack/__init__.py,sha256=FNVZvkchlc56ph-fm0NUca2FHQpLFmwV_o2Umz_nNjQ,450
2
+ substack/api.py,sha256=hnzIHC_t6VOjEzOCSngg9uaTQacyLtBYUlS3uQfbrJk,34830
3
+ substack/cli.py,sha256=56BTWxfr3gpAaJOLJsKRG7k6PPT7xSgLdp632H4mezA,26292
4
+ substack/exceptions.py,sha256=Y61XSPyxDxXPbVlioFvKhb94MRY0tpD389F5YdL1rGM,1390
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_mcp/mcp_server.py,sha256=M5XH3C4iWjzvCw3uOPbY2NFljTQAl8FLt14RKaTzka0,11324
10
+ python_substack-0.9.1.dist-info/METADATA,sha256=pDBPPtbe1r5CKfn2dC-qAsH0l0SuCGwGtFd9sPl3jmM,9208
11
+ python_substack-0.9.1.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
12
+ python_substack-0.9.1.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
13
+ python_substack-0.9.1.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
14
+ python_substack-0.9.1.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.9.1"
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
@@ -500,6 +500,7 @@ class Api:
500
500
  """
501
501
  Update an existing draft body from Markdown, with optional metadata changes.
502
502
  """
503
+ from substack import mdrender
503
504
  from substack.mdexport import document_to_markdown
504
505
  from substack.post import Post
505
506
 
@@ -517,6 +518,7 @@ class Api:
517
518
 
518
519
  _, remote_unsupported = document_to_markdown(draft_body)
519
520
 
521
+ image_markers = []
520
522
  post = Post(
521
523
  title=draft.get("title", ""),
522
524
  subtitle=(
@@ -526,30 +528,88 @@ class Api:
526
528
  audience=audience,
527
529
  write_comment_permissions=write_comment_permissions,
528
530
  )
529
- post.from_markdown(markdown, api=self)
531
+ rendered = mdrender.markdown_to_doc(
532
+ markdown, api=self, track_markers=image_markers
533
+ )
534
+ post.draft_body["content"] = rendered
530
535
 
531
536
  remote_images = _image_attrs(draft_body)
532
537
  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:
538
+
539
+ consumed_remote_indices = set()
540
+
541
+ for submitted, marker in zip(submitted_images, image_markers):
542
+ if marker is not None and marker.get("version") == 2:
543
+ bound_src = marker["src"]
544
+ matched_idx = None
545
+ for idx, r_img in enumerate(remote_images):
546
+ if (
547
+ idx not in consumed_remote_indices
548
+ and r_img.get("src") == bound_src
549
+ ):
550
+ matched_idx = idx
551
+ break
552
+
553
+ if matched_idx is None:
554
+ all_with_src = [
555
+ idx
556
+ for idx, r_img in enumerate(remote_images)
557
+ if r_img.get("src") == bound_src
558
+ ]
559
+ if not all_with_src:
560
+ raise ValueError(
561
+ f"Refusing to update: stale image marker bound to '{bound_src}'."
562
+ )
563
+ else:
564
+ raise ValueError(
565
+ f"Refusing to update: reused or duplicate image marker for '{bound_src}'."
566
+ )
567
+
568
+ consumed_remote_indices.add(matched_idx)
569
+ remote_img = remote_images[matched_idx]
540
570
  source, alt, href = (
541
571
  submitted.get("src"),
542
572
  submitted.get("alt"),
543
573
  submitted.get("href"),
544
574
  )
545
- submitted.update(matches[0])
575
+ submitted.update(remote_img)
576
+ submitted.update(marker["attrs"])
546
577
  submitted.update(
547
578
  {"src": source, "alt": alt, "href": href, "isProcessing": False}
548
579
  )
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
- )
580
+
581
+ elif marker is not None and marker.get("version") == 1:
582
+ sub_src = submitted.get("src")
583
+ matches = [
584
+ idx
585
+ for idx, r_img in enumerate(remote_images)
586
+ if r_img.get("src") == sub_src
587
+ and idx not in consumed_remote_indices
588
+ ]
589
+ if len(matches) == 1:
590
+ matched_idx = matches[0]
591
+ consumed_remote_indices.add(matched_idx)
592
+ remote_img = remote_images[matched_idx]
593
+ source, alt, href = (
594
+ submitted.get("src"),
595
+ submitted.get("alt"),
596
+ submitted.get("href"),
597
+ )
598
+ submitted.update(remote_img)
599
+ submitted.update(marker["attrs"])
600
+ submitted.update(
601
+ {"src": source, "alt": alt, "href": href, "isProcessing": False}
602
+ )
603
+ elif not allow_image_replacement:
604
+ raise ValueError(
605
+ "Refusing to update: image cannot be matched uniquely; set allow_image_replacement=True to proceed."
606
+ )
607
+
608
+ else:
609
+ if not allow_image_replacement:
610
+ raise ValueError(
611
+ "Refusing to update: image cannot be matched uniquely; set allow_image_replacement=True to proceed."
612
+ )
553
613
 
554
614
  _, submitted_unsupported = document_to_markdown(post.draft_body)
555
615
 
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):
@@ -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,,