python-substack 0.6.0__tar.gz → 0.8.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.6.0
3
+ Version: 0.8.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)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.6.0"
3
+ version = "0.8.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.6.0"
6
+ __version__ = "0.8.0"
7
7
  __url__ = "https://github.com/ma2za/python-substack"
8
8
  __download_url__ = "https://pypi.python.org/pypi/python-substack"
9
9
  __description__ = (
@@ -464,6 +464,110 @@ class Api:
464
464
  "unsupported_nodes": unsupported_nodes,
465
465
  }
466
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
+ allow_unsupported_change: bool = False,
482
+ ) -> dict:
483
+ """
484
+ Update an existing draft body from Markdown, with optional metadata changes.
485
+ """
486
+ from substack.mdexport import document_to_markdown
487
+ from substack.post import Post
488
+
489
+ draft = self.get_draft(draft_id)
490
+ draft_body = draft.get("draft_body")
491
+ if isinstance(draft_body, str):
492
+ try:
493
+ draft_body = json.loads(draft_body)
494
+ except json.JSONDecodeError as exc:
495
+ raise ValueError(
496
+ "Malformed draft body: draft_body is not valid JSON"
497
+ ) from exc
498
+ if not isinstance(draft_body, dict):
499
+ raise ValueError("Malformed draft body: draft_body must be a JSON object")
500
+
501
+ _, remote_unsupported = document_to_markdown(draft_body)
502
+
503
+ post = Post(
504
+ title=draft.get("title", ""),
505
+ subtitle=(
506
+ subtitle if subtitle is not None else (draft.get("subtitle") or "")
507
+ ),
508
+ user_id=self.get_user_id(),
509
+ audience=audience,
510
+ write_comment_permissions=write_comment_permissions,
511
+ )
512
+ post.from_markdown(markdown, api=self)
513
+
514
+ _, submitted_unsupported = document_to_markdown(post.draft_body)
515
+
516
+ if not allow_unsupported_change:
517
+ remote_serialized = [
518
+ json.dumps(n, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
519
+ for n in remote_unsupported
520
+ ]
521
+ submitted_serialized = [
522
+ json.dumps(n, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
523
+ for n in submitted_unsupported
524
+ ]
525
+ if sorted(remote_serialized) != sorted(submitted_serialized):
526
+ raise ValueError(
527
+ "Refusing to update: remote unsupported nodes are missing, duplicated, stale, or altered. "
528
+ "Export the draft first or set allow_unsupported_change=True to proceed."
529
+ )
530
+
531
+ unsupported_nodes = remote_unsupported
532
+
533
+ update_payload = {"draft_body": json.dumps(post.draft_body)}
534
+
535
+ if subtitle is not None:
536
+ update_payload["subtitle"] = subtitle
537
+ if audience is not None:
538
+ update_payload["audience"] = audience
539
+ if write_comment_permissions is not None:
540
+ update_payload["write_comment_permissions"] = write_comment_permissions
541
+ if search_engine_title is not None:
542
+ update_payload["search_engine_title"] = search_engine_title
543
+ if search_engine_description is not None:
544
+ update_payload["search_engine_description"] = search_engine_description
545
+ if slug is not None:
546
+ update_payload["slug"] = slug
547
+ if draft_section_id is not None:
548
+ update_payload["draft_section_id"] = draft_section_id
549
+
550
+ tags_result = None
551
+ updated_draft = draft
552
+
553
+ if not dry_run:
554
+ updated_draft = self.put_draft(draft_id, **update_payload)
555
+
556
+ tags_list = Api._normalize_tags(tags)
557
+ if tags_list:
558
+ tags_result = self.add_tags_to_post(draft_id, tags_list)
559
+
560
+ return {
561
+ "action": "update",
562
+ "draft_id": draft_id,
563
+ "dry_run": dry_run,
564
+ "changed": not dry_run,
565
+ "payload": update_payload,
566
+ "draft": updated_draft,
567
+ "tags": tags_result,
568
+ "unsupported_nodes": unsupported_nodes,
569
+ }
570
+
467
571
  def delete_draft(self, draft_id):
468
572
  """
469
573
 
@@ -309,6 +309,57 @@ 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 args.allow_unsupported_change and not args.yes:
314
+ raise CLIUsageError("--yes is required when using --allow-unsupported-change")
315
+
316
+ if not args.yes and (args.json_output or not sys.stdin.isatty()):
317
+ raise CLIUsageError("--yes is required in non-interactive or JSON mode")
318
+
319
+ markdown_file = Path(args.markdown_file)
320
+ if not markdown_file.exists():
321
+ raise CLIUsageError(f"File not found: {markdown_file}")
322
+
323
+ if not args.yes:
324
+ action = "update" if not args.dry_run else "dry-run update"
325
+ print(f"Ready to {action} draft {args.draft_id} from {markdown_file}")
326
+ print("Substack nodes that are not supported by Markdown export/import")
327
+ print("will cause the update to be refused to prevent data loss.")
328
+ print()
329
+ try:
330
+ response = input(f"Confirm {action}? [y/N]: ")
331
+ if response.lower() not in ["y", "yes"]:
332
+ print("Aborted.")
333
+ return
334
+ except EOFError as exc:
335
+ raise CLIUsageError(
336
+ f"Confirm {action} requires confirmation or --yes"
337
+ ) from exc
338
+
339
+ markdown = markdown_file.read_text(encoding="utf-8")
340
+
341
+ result = api.update_draft_from_markdown(
342
+ args.draft_id,
343
+ markdown,
344
+ subtitle=args.subtitle,
345
+ audience=args.audience,
346
+ write_comment_permissions=args.write_comment_permissions,
347
+ search_engine_title=args.search_engine_title,
348
+ search_engine_description=args.search_engine_description,
349
+ slug=args.slug,
350
+ draft_section_id=args.draft_section_id,
351
+ tags=args.tags,
352
+ dry_run=args.dry_run,
353
+ allow_unsupported_change=args.allow_unsupported_change,
354
+ )
355
+
356
+ if args.json_output:
357
+ _print_json(result)
358
+ else:
359
+ status = "Dry-run updated" if args.dry_run else "Updated"
360
+ print(f"{status} draft {args.draft_id}")
361
+
362
+
312
363
  def _drafts_export(api, args):
313
364
  output_path = Path(args.output) if args.output else None
314
365
  if output_path is not None and output_path.exists() and not args.force:
@@ -447,6 +498,24 @@ def _build_parser():
447
498
  drafts_create.add_argument("--tag", action="append", dest="tags", metavar="TAG")
448
499
  drafts_create.set_defaults(handler=_drafts_create)
449
500
 
501
+ drafts_update = draft_commands.add_parser(
502
+ "update", help="Update a draft from a Markdown file."
503
+ )
504
+ drafts_update.add_argument("draft_id", type=int)
505
+ drafts_update.add_argument("markdown_file", metavar="MARKDOWN_FILE")
506
+ drafts_update.add_argument("--subtitle")
507
+ drafts_update.add_argument("--audience")
508
+ drafts_update.add_argument("--write-comment-permissions")
509
+ drafts_update.add_argument("--search-engine-title")
510
+ drafts_update.add_argument("--search-engine-description")
511
+ drafts_update.add_argument("--slug")
512
+ drafts_update.add_argument("--draft-section-id", type=int)
513
+ drafts_update.add_argument("--tag", action="append", dest="tags", metavar="TAG")
514
+ drafts_update.add_argument("--dry-run", action="store_true")
515
+ drafts_update.add_argument("--allow-unsupported-change", action="store_true")
516
+ drafts_update.add_argument("--yes", action="store_true")
517
+ drafts_update.set_defaults(handler=_drafts_update)
518
+
450
519
  drafts_export = draft_commands.add_parser(
451
520
  "export", help="Export a draft to Markdown without modifying it."
452
521
  )