python-substack 0.5.0__tar.gz → 0.7.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.5.0
3
+ Version: 0.7.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
@@ -22,7 +22,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
22
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
23
  Provides-Extra: mcp
24
24
  Requires-Dist: PyYAML (>=6.0,<7.0)
25
- Requires-Dist: fastmcp (>=3.1.1,<4.0.0) ; extra == "mcp"
25
+ Requires-Dist: fastmcp (>=3.1.1,<5.0.0) ; extra == "mcp"
26
26
  Requires-Dist: markdown-it-py (>=3,<5)
27
27
  Requires-Dist: mdit-py-plugins (>=0.5,<0.7)
28
28
  Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
@@ -93,6 +93,7 @@ Substack result:
93
93
  - Upload local images referenced by Markdown.
94
94
  - Set audience, comment permissions, SEO metadata, slug, sections, and tags.
95
95
  - List and inspect publications and drafts.
96
+ - Export drafts to loss-aware Markdown backups without server writes.
96
97
  - Schedule, unschedule, publish, and delete drafts with explicit safeguards.
97
98
  - Use stable JSON envelopes in scripts and automation.
98
99
  - Authenticate with browser cookies or email and password.
@@ -143,6 +144,7 @@ Inspect publications and drafts:
143
144
  substack publications list
144
145
  substack drafts list --limit 10
145
146
  substack drafts get 12345
147
+ substack drafts export 12345 --output backup.md
146
148
  substack --publication-url https://example.substack.com drafts list
147
149
  ```
148
150
 
@@ -207,6 +209,14 @@ print(result["draft"]["id"])
207
209
  `create_draft_from_markdown` creates a draft by default. It publishes only when
208
210
  `publish=True` is passed.
209
211
 
212
+ Back up an existing draft without modifying it:
213
+
214
+ ```python
215
+ backup = api.export_draft_to_markdown(12345)
216
+ print(backup["markdown"])
217
+ print(backup["unsupported_nodes"])
218
+ ```
219
+
210
220
  For direct ProseMirror node construction, see the
211
221
  [low-level Python API](docs/low-level-api.md). YAML workflows are documented in
212
222
  [YAML drafts](docs/yaml.md).
@@ -57,6 +57,7 @@ Substack result:
57
57
  - Upload local images referenced by Markdown.
58
58
  - Set audience, comment permissions, SEO metadata, slug, sections, and tags.
59
59
  - List and inspect publications and drafts.
60
+ - Export drafts to loss-aware Markdown backups without server writes.
60
61
  - Schedule, unschedule, publish, and delete drafts with explicit safeguards.
61
62
  - Use stable JSON envelopes in scripts and automation.
62
63
  - Authenticate with browser cookies or email and password.
@@ -107,6 +108,7 @@ Inspect publications and drafts:
107
108
  substack publications list
108
109
  substack drafts list --limit 10
109
110
  substack drafts get 12345
111
+ substack drafts export 12345 --output backup.md
110
112
  substack --publication-url https://example.substack.com drafts list
111
113
  ```
112
114
 
@@ -171,6 +173,14 @@ print(result["draft"]["id"])
171
173
  `create_draft_from_markdown` creates a draft by default. It publishes only when
172
174
  `publish=True` is passed.
173
175
 
176
+ Back up an existing draft without modifying it:
177
+
178
+ ```python
179
+ backup = api.export_draft_to_markdown(12345)
180
+ print(backup["markdown"])
181
+ print(backup["unsupported_nodes"])
182
+ ```
183
+
174
184
  For direct ProseMirror node construction, see the
175
185
  [low-level Python API](docs/low-level-api.md). YAML workflows are documented in
176
186
  [YAML drafts](docs/yaml.md).
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.5.0"
3
+ version = "0.7.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"
@@ -41,7 +41,7 @@ python-dotenv = "^1.2.1"
41
41
  PyYAML = "^6.0"
42
42
  markdown-it-py = ">=3,<5"
43
43
  mdit-py-plugins = ">=0.5,<0.7"
44
- fastmcp = { version = "^3.1.1", optional = true }
44
+ fastmcp = { version = ">=3.1.1,<5.0.0", optional = true }
45
45
 
46
46
  [tool.poetry.extras]
47
47
  mcp = ["fastmcp"]
@@ -49,6 +49,7 @@ mcp = ["fastmcp"]
49
49
  [tool.poetry.group.dev.dependencies]
50
50
  pytest = "^9.1.1"
51
51
  pre-commit = "^4.6.1"
52
+ twine = "^7.0.0"
52
53
 
53
54
  [tool.pytest.ini_options]
54
55
  markers = [
@@ -3,7 +3,7 @@
3
3
  __author__ = "Paolo Mazza"
4
4
  __email__ = "mazzapaolo2019@gmail.com"
5
5
  __license__ = "MIT License"
6
- __version__ = "0.5.0"
6
+ __version__ = "0.7.0"
7
7
  __url__ = "https://github.com/ma2za/python-substack"
8
8
  __download_url__ = "https://pypi.python.org/pypi/python-substack"
9
9
  __description__ = (
@@ -442,6 +442,117 @@ class Api:
442
442
  response = self._session.get(f"{self.publication_url}/drafts/{draft_id}")
443
443
  return Api._handle_response(response=response)
444
444
 
445
+ def export_draft_to_markdown(self, draft_id):
446
+ from substack.mdexport import document_to_markdown
447
+
448
+ draft = self.get_draft(draft_id)
449
+ draft_body = draft.get("draft_body")
450
+ if isinstance(draft_body, str):
451
+ try:
452
+ draft_body = json.loads(draft_body)
453
+ except json.JSONDecodeError as exc:
454
+ raise ValueError(
455
+ "Malformed draft body: draft_body is not valid JSON"
456
+ ) from exc
457
+ if not isinstance(draft_body, dict):
458
+ raise ValueError("Malformed draft body: draft_body must be a JSON object")
459
+
460
+ markdown, unsupported_nodes = document_to_markdown(draft_body)
461
+ return {
462
+ "draft": draft,
463
+ "markdown": markdown,
464
+ "unsupported_nodes": unsupported_nodes,
465
+ }
466
+
467
+ def update_draft_from_markdown(
468
+ self,
469
+ draft_id: int,
470
+ markdown: str,
471
+ *,
472
+ subtitle: str = None,
473
+ audience: str = None,
474
+ write_comment_permissions: str = None,
475
+ search_engine_title: str = None,
476
+ search_engine_description: str = None,
477
+ slug: str = None,
478
+ draft_section_id: int = None,
479
+ tags=None,
480
+ dry_run: bool = False,
481
+ ) -> dict:
482
+ """
483
+ Update an existing draft body from Markdown, with optional metadata changes.
484
+ """
485
+ from substack.mdexport import document_to_markdown
486
+ from substack.post import Post
487
+
488
+ draft = self.get_draft(draft_id)
489
+ draft_body = draft.get("draft_body")
490
+ if isinstance(draft_body, str):
491
+ try:
492
+ draft_body = json.loads(draft_body)
493
+ except json.JSONDecodeError as exc:
494
+ raise ValueError(
495
+ "Malformed draft body: draft_body is not valid JSON"
496
+ ) from exc
497
+ if not isinstance(draft_body, dict):
498
+ raise ValueError("Malformed draft body: draft_body must be a JSON object")
499
+
500
+ _, unsupported_nodes = document_to_markdown(draft_body)
501
+ if unsupported_nodes:
502
+ raise ValueError(
503
+ "Refusing to update: remote draft contains unsupported Substack nodes. "
504
+ "Export it first or remove the nodes manually to avoid data loss."
505
+ )
506
+
507
+ post = Post(
508
+ title=draft.get("title", ""),
509
+ subtitle=(
510
+ subtitle if subtitle is not None else (draft.get("subtitle") or "")
511
+ ),
512
+ user_id=self.get_user_id(),
513
+ audience=audience,
514
+ write_comment_permissions=write_comment_permissions,
515
+ )
516
+ post.from_markdown(markdown, api=self)
517
+
518
+ update_payload = {"draft_body": json.dumps(post.draft_body)}
519
+
520
+ if subtitle is not None:
521
+ update_payload["subtitle"] = subtitle
522
+ if audience is not None:
523
+ update_payload["audience"] = audience
524
+ if write_comment_permissions is not None:
525
+ update_payload["write_comment_permissions"] = write_comment_permissions
526
+ if search_engine_title is not None:
527
+ update_payload["search_engine_title"] = search_engine_title
528
+ if search_engine_description is not None:
529
+ update_payload["search_engine_description"] = search_engine_description
530
+ if slug is not None:
531
+ update_payload["slug"] = slug
532
+ if draft_section_id is not None:
533
+ update_payload["draft_section_id"] = draft_section_id
534
+
535
+ tags_result = None
536
+ updated_draft = draft
537
+
538
+ if not dry_run:
539
+ updated_draft = self.put_draft(draft_id, **update_payload)
540
+
541
+ tags_list = Api._normalize_tags(tags)
542
+ if tags_list:
543
+ tags_result = self.add_tags_to_post(draft_id, tags_list)
544
+
545
+ return {
546
+ "action": "update",
547
+ "draft_id": draft_id,
548
+ "dry_run": dry_run,
549
+ "changed": not dry_run,
550
+ "payload": update_payload,
551
+ "draft": updated_draft,
552
+ "tags": tags_result,
553
+ "unsupported_nodes": unsupported_nodes,
554
+ }
555
+
445
556
  def delete_draft(self, draft_id):
446
557
  """
447
558
 
@@ -309,6 +309,79 @@ def _drafts_create(api, args):
309
309
  print(f"Created draft {draft.get('id')}: {title}")
310
310
 
311
311
 
312
+ def _drafts_update(api, args):
313
+ if not args.yes and (args.json_output or not sys.stdin.isatty()):
314
+ raise CLIUsageError("--yes is required in non-interactive or JSON mode")
315
+
316
+ markdown_file = Path(args.markdown_file)
317
+ if not markdown_file.exists():
318
+ raise CLIUsageError(f"File not found: {markdown_file}")
319
+
320
+ if not args.yes:
321
+ action = "update" if not args.dry_run else "dry-run update"
322
+ print(f"Ready to {action} draft {args.draft_id} from {markdown_file}")
323
+ print("Substack nodes that are not supported by Markdown export/import")
324
+ print("will cause the update to be refused to prevent data loss.")
325
+ print()
326
+ try:
327
+ response = input(f"Confirm {action}? [y/N]: ")
328
+ if response.lower() not in ["y", "yes"]:
329
+ print("Aborted.")
330
+ return
331
+ except EOFError as exc:
332
+ raise CLIUsageError(
333
+ f"Confirm {action} requires confirmation or --yes"
334
+ ) from exc
335
+
336
+ markdown = markdown_file.read_text(encoding="utf-8")
337
+
338
+ result = api.update_draft_from_markdown(
339
+ args.draft_id,
340
+ markdown,
341
+ subtitle=args.subtitle,
342
+ audience=args.audience,
343
+ write_comment_permissions=args.write_comment_permissions,
344
+ search_engine_title=args.search_engine_title,
345
+ search_engine_description=args.search_engine_description,
346
+ slug=args.slug,
347
+ draft_section_id=args.draft_section_id,
348
+ tags=args.tags,
349
+ dry_run=args.dry_run,
350
+ )
351
+
352
+ if args.json_output:
353
+ _print_json(result)
354
+ else:
355
+ status = "Dry-run updated" if args.dry_run else "Updated"
356
+ print(f"{status} draft {args.draft_id}")
357
+
358
+
359
+ def _drafts_export(api, args):
360
+ output_path = Path(args.output) if args.output else None
361
+ if output_path is not None and output_path.exists() and not args.force:
362
+ raise CLIUsageError(
363
+ f"Output file already exists: {output_path}; use --force to overwrite"
364
+ )
365
+
366
+ result = api.export_draft_to_markdown(args.draft_id)
367
+ markdown = result["markdown"]
368
+ if output_path is not None:
369
+ output_path.write_text(markdown, encoding="utf-8")
370
+
371
+ payload = {
372
+ "action": "export",
373
+ "draft_id": args.draft_id,
374
+ "markdown": markdown,
375
+ "unsupported_nodes": result["unsupported_nodes"],
376
+ }
377
+ if args.json_output:
378
+ _print_json(payload)
379
+ elif output_path is not None:
380
+ print(f"Exported draft {args.draft_id} to {output_path}")
381
+ else:
382
+ print(markdown, end="")
383
+
384
+
312
385
  def _drafts_schedule(api, args):
313
386
  scheduled_at = _parse_schedule(args.at)
314
387
  result = api.schedule_draft(args.draft_id, scheduled_at)
@@ -421,6 +494,31 @@ def _build_parser():
421
494
  drafts_create.add_argument("--tag", action="append", dest="tags", metavar="TAG")
422
495
  drafts_create.set_defaults(handler=_drafts_create)
423
496
 
497
+ drafts_update = draft_commands.add_parser(
498
+ "update", help="Update a draft from a Markdown file."
499
+ )
500
+ drafts_update.add_argument("draft_id", type=int)
501
+ drafts_update.add_argument("markdown_file", metavar="MARKDOWN_FILE")
502
+ drafts_update.add_argument("--subtitle")
503
+ drafts_update.add_argument("--audience")
504
+ drafts_update.add_argument("--write-comment-permissions")
505
+ drafts_update.add_argument("--search-engine-title")
506
+ drafts_update.add_argument("--search-engine-description")
507
+ drafts_update.add_argument("--slug")
508
+ drafts_update.add_argument("--draft-section-id", type=int)
509
+ drafts_update.add_argument("--tag", action="append", dest="tags", metavar="TAG")
510
+ drafts_update.add_argument("--dry-run", action="store_true")
511
+ drafts_update.add_argument("--yes", action="store_true")
512
+ drafts_update.set_defaults(handler=_drafts_update)
513
+
514
+ drafts_export = draft_commands.add_parser(
515
+ "export", help="Export a draft to Markdown without modifying it."
516
+ )
517
+ drafts_export.add_argument("draft_id", type=int)
518
+ drafts_export.add_argument("--output", metavar="PATH")
519
+ drafts_export.add_argument("--force", action="store_true")
520
+ drafts_export.set_defaults(handler=_drafts_export)
521
+
424
522
  drafts_schedule = draft_commands.add_parser("schedule", help="Schedule a draft.")
425
523
  drafts_schedule.add_argument("draft_id", type=int)
426
524
  drafts_schedule.add_argument("--at", required=True)