python-substack 0.8.0__tar.gz → 0.9.0__tar.gz

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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-substack
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -94,6 +94,7 @@ Substack result:
94
94
  - Set audience, comment permissions, SEO metadata, slug, sections, and tags.
95
95
  - List and inspect publications and drafts.
96
96
  - Export drafts to loss-aware Markdown backups without server writes.
97
+ - Safely update drafts from Markdown, preserving unsupported editor widgets by default.
97
98
  - Schedule, unschedule, publish, and delete drafts with explicit safeguards.
98
99
  - Use stable JSON envelopes in scripts and automation.
99
100
  - Authenticate with browser cookies or email and password.
@@ -145,6 +146,7 @@ substack publications list
145
146
  substack drafts list --limit 10
146
147
  substack drafts get 12345
147
148
  substack drafts export 12345 --output backup.md
149
+ substack drafts update 12345 revised-post.md --dry-run --yes
148
150
  substack --publication-url https://example.substack.com drafts list
149
151
  ```
150
152
 
@@ -217,6 +219,17 @@ print(backup["markdown"])
217
219
  print(backup["unsupported_nodes"])
218
220
  ```
219
221
 
222
+ Review a Markdown update before writing it, then update deliberately:
223
+
224
+ ```python
225
+ result = api.update_draft_from_markdown(12345, "# Revised draft", dry_run=True)
226
+ result = api.update_draft_from_markdown(12345, "# Revised draft")
227
+ ```
228
+
229
+ Unsupported editor widgets exported as `python-substack-node:v1` markers are
230
+ preserved by default. An update refuses to remove or alter them unless
231
+ `allow_unsupported_change=True` is explicitly passed.
232
+
220
233
  For direct ProseMirror node construction, see the
221
234
  [low-level Python API](docs/low-level-api.md). YAML workflows are documented in
222
235
  [YAML drafts](docs/yaml.md).
@@ -269,11 +282,12 @@ See [MCP server](docs/mcp.md) for the tool list and safety notes.
269
282
  - [Python SDK](docs/python-sdk.md)
270
283
  - [Authentication](docs/authentication.md)
271
284
  - [Markdown reference](docs/markdown.md)
285
+ - [Draft backup and safe updates](docs/cli.md)
272
286
  - [Legacy CLI commands](docs/legacy-cli.md)
273
287
  - [Low-level Python API](docs/low-level-api.md)
274
288
  - [YAML drafts](docs/yaml.md)
275
289
  - [MCP server](docs/mcp.md)
276
- - [Gemini CLI with Vertex AI](docs/gemini-vertex-ai.md)
290
+ - [Agent workflow](docs/agents.md)
277
291
  - [Safety and publishing behavior](docs/safety.md)
278
292
  - [Troubleshooting](docs/troubleshooting.md)
279
293
  - [Compatibility policy](docs/compatibility.md)
@@ -58,6 +58,7 @@ Substack result:
58
58
  - Set audience, comment permissions, SEO metadata, slug, sections, and tags.
59
59
  - List and inspect publications and drafts.
60
60
  - Export drafts to loss-aware Markdown backups without server writes.
61
+ - Safely update drafts from Markdown, preserving unsupported editor widgets by default.
61
62
  - Schedule, unschedule, publish, and delete drafts with explicit safeguards.
62
63
  - Use stable JSON envelopes in scripts and automation.
63
64
  - Authenticate with browser cookies or email and password.
@@ -109,6 +110,7 @@ substack publications list
109
110
  substack drafts list --limit 10
110
111
  substack drafts get 12345
111
112
  substack drafts export 12345 --output backup.md
113
+ substack drafts update 12345 revised-post.md --dry-run --yes
112
114
  substack --publication-url https://example.substack.com drafts list
113
115
  ```
114
116
 
@@ -181,6 +183,17 @@ print(backup["markdown"])
181
183
  print(backup["unsupported_nodes"])
182
184
  ```
183
185
 
186
+ Review a Markdown update before writing it, then update deliberately:
187
+
188
+ ```python
189
+ result = api.update_draft_from_markdown(12345, "# Revised draft", dry_run=True)
190
+ result = api.update_draft_from_markdown(12345, "# Revised draft")
191
+ ```
192
+
193
+ Unsupported editor widgets exported as `python-substack-node:v1` markers are
194
+ preserved by default. An update refuses to remove or alter them unless
195
+ `allow_unsupported_change=True` is explicitly passed.
196
+
184
197
  For direct ProseMirror node construction, see the
185
198
  [low-level Python API](docs/low-level-api.md). YAML workflows are documented in
186
199
  [YAML drafts](docs/yaml.md).
@@ -233,11 +246,12 @@ See [MCP server](docs/mcp.md) for the tool list and safety notes.
233
246
  - [Python SDK](docs/python-sdk.md)
234
247
  - [Authentication](docs/authentication.md)
235
248
  - [Markdown reference](docs/markdown.md)
249
+ - [Draft backup and safe updates](docs/cli.md)
236
250
  - [Legacy CLI commands](docs/legacy-cli.md)
237
251
  - [Low-level Python API](docs/low-level-api.md)
238
252
  - [YAML drafts](docs/yaml.md)
239
253
  - [MCP server](docs/mcp.md)
240
- - [Gemini CLI with Vertex AI](docs/gemini-vertex-ai.md)
254
+ - [Agent workflow](docs/agents.md)
241
255
  - [Safety and publishing behavior](docs/safety.md)
242
256
  - [Troubleshooting](docs/troubleshooting.md)
243
257
  - [Compatibility policy](docs/compatibility.md)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP."
5
5
  authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
6
6
  license = "MIT"
@@ -3,7 +3,7 @@
3
3
  __author__ = "Paolo Mazza"
4
4
  __email__ = "mazzapaolo2019@gmail.com"
5
5
  __license__ = "MIT License"
6
- __version__ = "0.8.0"
6
+ __version__ = "0.9.0"
7
7
  __url__ = "https://github.com/ma2za/python-substack"
8
8
  __download_url__ = "https://pypi.python.org/pypi/python-substack"
9
9
  __description__ = (
@@ -21,6 +21,19 @@ logger = logging.getLogger(__name__)
21
21
  __all__ = ["Api"]
22
22
 
23
23
 
24
+ def _image_attrs(document):
25
+ images = []
26
+ if isinstance(document, dict):
27
+ if document.get("type") == "image2" and isinstance(document.get("attrs"), dict):
28
+ images.append(document["attrs"])
29
+ for value in document.values():
30
+ images.extend(_image_attrs(value))
31
+ elif isinstance(document, list):
32
+ for value in document:
33
+ images.extend(_image_attrs(value))
34
+ return images
35
+
36
+
24
37
  class Api:
25
38
  """
26
39
 
@@ -432,7 +445,10 @@ class Api:
432
445
  f"{self.publication_url}/drafts",
433
446
  params={"filter": filter, "offset": offset, "limit": limit},
434
447
  )
435
- return Api._handle_response(response=response)
448
+ drafts = Api._handle_response(response=response)
449
+ if isinstance(drafts, dict) and isinstance(drafts.get("posts"), list):
450
+ return drafts["posts"]
451
+ return drafts
436
452
 
437
453
  def get_draft(self, draft_id):
438
454
  """
@@ -479,6 +495,7 @@ class Api:
479
495
  tags=None,
480
496
  dry_run: bool = False,
481
497
  allow_unsupported_change: bool = False,
498
+ allow_image_replacement: bool = False,
482
499
  ) -> dict:
483
500
  """
484
501
  Update an existing draft body from Markdown, with optional metadata changes.
@@ -511,6 +528,29 @@ class Api:
511
528
  )
512
529
  post.from_markdown(markdown, api=self)
513
530
 
531
+ remote_images = _image_attrs(draft_body)
532
+ 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:
540
+ source, alt, href = (
541
+ submitted.get("src"),
542
+ submitted.get("alt"),
543
+ submitted.get("href"),
544
+ )
545
+ submitted.update(matches[0])
546
+ submitted.update(
547
+ {"src": source, "alt": alt, "href": href, "isProcessing": False}
548
+ )
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
+ )
553
+
514
554
  _, submitted_unsupported = document_to_markdown(post.draft_body)
515
555
 
516
556
  if not allow_unsupported_change:
@@ -312,6 +312,8 @@ def _drafts_create(api, args):
312
312
  def _drafts_update(api, args):
313
313
  if args.allow_unsupported_change and not args.yes:
314
314
  raise CLIUsageError("--yes is required when using --allow-unsupported-change")
315
+ if args.allow_image_replacement and not args.yes:
316
+ raise CLIUsageError("--yes is required when using --allow-image-replacement")
315
317
 
316
318
  if not args.yes and (args.json_output or not sys.stdin.isatty()):
317
319
  raise CLIUsageError("--yes is required in non-interactive or JSON mode")
@@ -351,6 +353,7 @@ def _drafts_update(api, args):
351
353
  tags=args.tags,
352
354
  dry_run=args.dry_run,
353
355
  allow_unsupported_change=args.allow_unsupported_change,
356
+ allow_image_replacement=args.allow_image_replacement,
354
357
  )
355
358
 
356
359
  if args.json_output:
@@ -513,6 +516,7 @@ def _build_parser():
513
516
  drafts_update.add_argument("--tag", action="append", dest="tags", metavar="TAG")
514
517
  drafts_update.add_argument("--dry-run", action="store_true")
515
518
  drafts_update.add_argument("--allow-unsupported-change", action="store_true")
519
+ drafts_update.add_argument("--allow-image-replacement", action="store_true")
516
520
  drafts_update.add_argument("--yes", action="store_true")
517
521
  drafts_update.set_defaults(handler=_drafts_update)
518
522
 
@@ -57,6 +57,21 @@ def _marker(node, unsupported):
57
57
  return f"<!-- python-substack-node:v1 {encoded} -->"
58
58
 
59
59
 
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"}
65
+ }
66
+ if not preserved:
67
+ return ""
68
+ payload = json.dumps(
69
+ preserved, ensure_ascii=False, separators=(",", ":"), sort_keys=True
70
+ ).encode("utf-8")
71
+ encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
72
+ return f"\n<!-- python-substack-image:v1 {encoded} -->"
73
+
74
+
60
75
  def _require_dict(node, context):
61
76
  if not isinstance(node, dict):
62
77
  raise ValueError(f"Malformed draft body: {context} must be an object")
@@ -247,7 +262,7 @@ def _render_image(node, unsupported):
247
262
  if not isinstance(href, str):
248
263
  return _marker(node, unsupported)
249
264
  image_markdown = f"[{image_markdown}]({_escape_destination(href)})"
250
- return image_markdown
265
+ return image_markdown + _image_marker(attrs)
251
266
 
252
267
 
253
268
  def _render_footnote(node, unsupported):
@@ -21,7 +21,9 @@ import base64
21
21
  import copy
22
22
  import json
23
23
  import re
24
+ from pathlib import Path
24
25
  from typing import Dict, List, Optional
26
+ from urllib.parse import unquote, urlsplit
25
27
 
26
28
  from markdown_it import MarkdownIt
27
29
  from markdown_it.tree import SyntaxTreeNode
@@ -43,22 +45,24 @@ _MARK_FOR = {
43
45
  }
44
46
 
45
47
 
46
- def parse_node_marker(comment_content: str) -> dict | None:
48
+ def parse_node_marker(comment_content: str) -> dict | None:
47
49
  """
48
50
  Parse a python-substack-node:v1 comment marker and return the parsed JSON dictionary.
49
-
51
+
50
52
  If it is not a python-substack-node:v1 marker, returns None.
51
53
  If it is an attempted marker but is corrupt/malformed, raises ValueError.
52
54
  """
53
55
  clean = comment_content.strip()
54
- match = re.match(r"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean)
56
+ match = re.match(
57
+ r"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean
58
+ )
55
59
  if not match:
56
60
  if "python-substack-node:v1" in clean:
57
61
  raise ValueError("Corrupt marker format")
58
62
  return None
59
-
63
+
60
64
  encoded = match.group(1)
61
-
65
+
62
66
  try:
63
67
  padding = len(encoded) % 4
64
68
  if padding:
@@ -84,7 +88,29 @@ def parse_node_marker(comment_content: str) -> dict | None:
84
88
  if not isinstance(node_type, str) or not node_type:
85
89
  raise ValueError("Marker payload has missing or empty 'type'")
86
90
 
87
- return data
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
88
114
 
89
115
 
90
116
  def _make_parser() -> MarkdownIt:
@@ -173,13 +199,28 @@ def _only_image(inline: SyntaxTreeNode) -> Optional[SyntaxTreeNode]:
173
199
 
174
200
  def _captioned_image(img: SyntaxTreeNode, api) -> Dict:
175
201
  src = img.attrs.get("src", "")
176
- if src.startswith("/"):
177
- src = src[1:]
178
- if api is not None and not src.startswith("http"):
202
+ if api is not None and (
203
+ urlsplit(src).scheme.lower() not in ("http", "https")
204
+ and not src.startswith("//")
205
+ ):
206
+ # Markdown destinations encode spaces and non-ASCII characters as URLs.
207
+ path = Path(unquote(src)).expanduser()
208
+ if not path.is_file() and src.startswith("/"):
209
+ # Preserve legacy root-relative asset paths only when they resolve
210
+ # to a real file; an existing absolute path always takes precedence.
211
+ relative = Path(unquote(src[1:]))
212
+ if relative.is_file():
213
+ path = relative
214
+ if not path.is_file():
215
+ raise FileNotFoundError(f"Local image file not found: {path}")
179
216
  try:
180
- src = api.get_image(src).get("url")
181
- except Exception:
182
- pass
217
+ uploaded = api.get_image(str(path))
218
+ url = uploaded.get("url")
219
+ if not isinstance(url, str) or not url.startswith(("https://", "http://")):
220
+ raise ValueError("Upload did not return an HTTP image URL")
221
+ except Exception as exc:
222
+ raise ValueError(f"Failed to upload local image: {path}") from exc
223
+ src = url
183
224
  # markdown-it stores the image alt text as the node's content, not in attrs.
184
225
  alt = img.content or img.attrs.get("alt") or None
185
226
  # Standard markdown image title `![alt](src "caption")` maps to Substack's caption node.
@@ -292,10 +333,18 @@ def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]:
292
333
 
293
334
  ctx: Dict = {"order": []}
294
335
  out: List[Dict] = []
295
- for node in tree.children:
296
- if node.type == "footnote_block":
297
- continue
298
- out.extend(_render_block(node, api, ctx))
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))
299
348
 
300
349
  # Emit one footnote block per reference, in anchor order, numbered to match.
301
350
  for number, footnote_id in enumerate(ctx["order"], start=1):
File without changes