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.
- {python_substack-0.6.0 → python_substack-0.8.0}/PKG-INFO +2 -2
- {python_substack-0.6.0 → python_substack-0.8.0}/pyproject.toml +3 -2
- {python_substack-0.6.0 → python_substack-0.8.0}/substack/__init__.py +1 -1
- {python_substack-0.6.0 → python_substack-0.8.0}/substack/api.py +104 -0
- {python_substack-0.6.0 → python_substack-0.8.0}/substack/cli.py +69 -0
- {python_substack-0.6.0 → python_substack-0.8.0}/substack/mdrender.py +305 -248
- {python_substack-0.6.0 → python_substack-0.8.0}/substack_mcp/mcp_server.py +386 -386
- {python_substack-0.6.0 → python_substack-0.8.0}/LICENSE +0 -0
- {python_substack-0.6.0 → python_substack-0.8.0}/README.md +0 -0
- {python_substack-0.6.0 → python_substack-0.8.0}/substack/exceptions.py +0 -0
- {python_substack-0.6.0 → python_substack-0.8.0}/substack/mdexport.py +0 -0
- {python_substack-0.6.0 → python_substack-0.8.0}/substack/nodes.py +0 -0
- {python_substack-0.6.0 → python_substack-0.8.0}/substack/post.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 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,<
|
|
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.
|
|
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 = "
|
|
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
|
+
__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
|
)
|