python-substack 0.9.0__py3-none-any.whl → 0.10.0__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.
- {python_substack-0.9.0.dist-info → python_substack-0.10.0.dist-info}/METADATA +2 -4
- python_substack-0.10.0.dist-info/RECORD +15 -0
- substack/__init__.py +1 -1
- substack/api.py +142 -15
- substack/cli.py +37 -2
- substack/exceptions.py +28 -2
- substack/mdexport.py +9 -9
- substack/mdrender.py +78 -37
- substack/revision.py +254 -0
- python_substack-0.9.0.dist-info/RECORD +0 -14
- {python_substack-0.9.0.dist-info → python_substack-0.10.0.dist-info}/WHEEL +0 -0
- {python_substack-0.9.0.dist-info → python_substack-0.10.0.dist-info}/entry_points.txt +0 -0
- {python_substack-0.9.0.dist-info → python_substack-0.10.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
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,15 @@
|
|
|
1
|
+
substack/__init__.py,sha256=1z7cdQwJPM4msiHdIoSzm4etUIu5Jcc5CKmFDHcnODA,451
|
|
2
|
+
substack/api.py,sha256=KWEJXjlxZY1sQ9j5qZSGxHfaATyE3O9Nup_elsXBHNU,37927
|
|
3
|
+
substack/cli.py,sha256=dgUTgZeX4sgJDXsFmPIJRBpNGgGQyLR2OpXoEcoHQa0,27586
|
|
4
|
+
substack/exceptions.py,sha256=xuckEoS-PYGYt-9zvuG8CDbr4WncpuKqGXN34yBvGnE,2140
|
|
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/revision.py,sha256=guEJ8vd38eqHHDqwHL0baf3v02U7RO34Li3D2pvCka8,8796
|
|
10
|
+
substack_mcp/mcp_server.py,sha256=M5XH3C4iWjzvCw3uOPbY2NFljTQAl8FLt14RKaTzka0,11324
|
|
11
|
+
python_substack-0.10.0.dist-info/METADATA,sha256=fgjfdA7h0iQv_kCqxFLFw3Gemhd-LHeJOKhhEZFxthc,9209
|
|
12
|
+
python_substack-0.10.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
13
|
+
python_substack-0.10.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
|
|
14
|
+
python_substack-0.10.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
15
|
+
python_substack-0.10.0.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.
|
|
6
|
+
__version__ = "0.10.0"
|
|
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
|
@@ -460,6 +460,7 @@ class Api:
|
|
|
460
460
|
|
|
461
461
|
def export_draft_to_markdown(self, draft_id):
|
|
462
462
|
from substack.mdexport import document_to_markdown
|
|
463
|
+
from substack.revision import compute_draft_revision, format_revision_marker
|
|
463
464
|
|
|
464
465
|
draft = self.get_draft(draft_id)
|
|
465
466
|
draft_body = draft.get("draft_body")
|
|
@@ -474,9 +475,15 @@ class Api:
|
|
|
474
475
|
raise ValueError("Malformed draft body: draft_body must be a JSON object")
|
|
475
476
|
|
|
476
477
|
markdown, unsupported_nodes = document_to_markdown(draft_body)
|
|
478
|
+
revision = compute_draft_revision(draft)
|
|
479
|
+
revision_marker = format_revision_marker(revision)
|
|
480
|
+
full_markdown = (
|
|
481
|
+
f"{revision_marker}\n\n{markdown}" if markdown else f"{revision_marker}\n"
|
|
482
|
+
)
|
|
477
483
|
return {
|
|
478
484
|
"draft": draft,
|
|
479
|
-
"markdown":
|
|
485
|
+
"markdown": full_markdown,
|
|
486
|
+
"revision": revision,
|
|
480
487
|
"unsupported_nodes": unsupported_nodes,
|
|
481
488
|
}
|
|
482
489
|
|
|
@@ -496,12 +503,22 @@ class Api:
|
|
|
496
503
|
dry_run: bool = False,
|
|
497
504
|
allow_unsupported_change: bool = False,
|
|
498
505
|
allow_image_replacement: bool = False,
|
|
506
|
+
expected_revision=None,
|
|
507
|
+
allow_conflict: bool = False,
|
|
508
|
+
force: bool = False,
|
|
499
509
|
) -> dict:
|
|
500
510
|
"""
|
|
501
511
|
Update an existing draft body from Markdown, with optional metadata changes.
|
|
502
512
|
"""
|
|
513
|
+
from substack import mdrender
|
|
514
|
+
from substack.exceptions import DraftConflictError
|
|
503
515
|
from substack.mdexport import document_to_markdown
|
|
504
516
|
from substack.post import Post
|
|
517
|
+
from substack.revision import (
|
|
518
|
+
compare_draft_revisions,
|
|
519
|
+
compute_draft_revision,
|
|
520
|
+
parse_revision_marker,
|
|
521
|
+
)
|
|
505
522
|
|
|
506
523
|
draft = self.get_draft(draft_id)
|
|
507
524
|
draft_body = draft.get("draft_body")
|
|
@@ -515,8 +532,53 @@ class Api:
|
|
|
515
532
|
if not isinstance(draft_body, dict):
|
|
516
533
|
raise ValueError("Malformed draft body: draft_body must be a JSON object")
|
|
517
534
|
|
|
535
|
+
current_rev = compute_draft_revision(draft)
|
|
536
|
+
|
|
537
|
+
target_expected_rev = None
|
|
538
|
+
if expected_revision is not None:
|
|
539
|
+
if isinstance(expected_revision, dict):
|
|
540
|
+
target_expected_rev = expected_revision
|
|
541
|
+
elif isinstance(expected_revision, str):
|
|
542
|
+
target_expected_rev = {"revision": expected_revision}
|
|
543
|
+
else:
|
|
544
|
+
raise ValueError("expected_revision must be a revision dict or string")
|
|
545
|
+
else:
|
|
546
|
+
target_expected_rev = parse_revision_marker(markdown)
|
|
547
|
+
|
|
548
|
+
override_enabled = bool(allow_conflict or force)
|
|
549
|
+
conflict_detected = False
|
|
550
|
+
mismatches = []
|
|
551
|
+
|
|
552
|
+
if target_expected_rev is None:
|
|
553
|
+
conflict_detected = True
|
|
554
|
+
mismatches = [
|
|
555
|
+
"Markdown file is missing revision metadata (unprotected update); safe conflict comparison is impossible."
|
|
556
|
+
]
|
|
557
|
+
if not override_enabled and not dry_run:
|
|
558
|
+
raise DraftConflictError(
|
|
559
|
+
"Refusing to update: Markdown file is missing revision metadata (unprotected update). "
|
|
560
|
+
"Export the draft first or pass allow_conflict=True to proceed.",
|
|
561
|
+
mismatches=mismatches,
|
|
562
|
+
expected_revision=None,
|
|
563
|
+
current_revision=current_rev,
|
|
564
|
+
)
|
|
565
|
+
else:
|
|
566
|
+
mismatches = compare_draft_revisions(target_expected_rev, current_rev)
|
|
567
|
+
if mismatches:
|
|
568
|
+
conflict_detected = True
|
|
569
|
+
if not override_enabled and not dry_run:
|
|
570
|
+
details = "; ".join(mismatches)
|
|
571
|
+
raise DraftConflictError(
|
|
572
|
+
f"Refusing to update: remote draft has changed since export ({details}). "
|
|
573
|
+
"Re-export the draft or pass allow_conflict=True to proceed.",
|
|
574
|
+
mismatches=mismatches,
|
|
575
|
+
expected_revision=target_expected_rev,
|
|
576
|
+
current_revision=current_rev,
|
|
577
|
+
)
|
|
578
|
+
|
|
518
579
|
_, remote_unsupported = document_to_markdown(draft_body)
|
|
519
580
|
|
|
581
|
+
image_markers = []
|
|
520
582
|
post = Post(
|
|
521
583
|
title=draft.get("title", ""),
|
|
522
584
|
subtitle=(
|
|
@@ -526,30 +588,88 @@ class Api:
|
|
|
526
588
|
audience=audience,
|
|
527
589
|
write_comment_permissions=write_comment_permissions,
|
|
528
590
|
)
|
|
529
|
-
|
|
591
|
+
rendered = mdrender.markdown_to_doc(
|
|
592
|
+
markdown, api=self, track_markers=image_markers
|
|
593
|
+
)
|
|
594
|
+
post.draft_body["content"] = rendered
|
|
530
595
|
|
|
531
596
|
remote_images = _image_attrs(draft_body)
|
|
532
597
|
submitted_images = _image_attrs(post.draft_body)
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
598
|
+
|
|
599
|
+
consumed_remote_indices = set()
|
|
600
|
+
|
|
601
|
+
for submitted, marker in zip(submitted_images, image_markers):
|
|
602
|
+
if marker is not None and marker.get("version") == 2:
|
|
603
|
+
bound_src = marker["src"]
|
|
604
|
+
matched_idx = None
|
|
605
|
+
for idx, r_img in enumerate(remote_images):
|
|
606
|
+
if (
|
|
607
|
+
idx not in consumed_remote_indices
|
|
608
|
+
and r_img.get("src") == bound_src
|
|
609
|
+
):
|
|
610
|
+
matched_idx = idx
|
|
611
|
+
break
|
|
612
|
+
|
|
613
|
+
if matched_idx is None:
|
|
614
|
+
all_with_src = [
|
|
615
|
+
idx
|
|
616
|
+
for idx, r_img in enumerate(remote_images)
|
|
617
|
+
if r_img.get("src") == bound_src
|
|
618
|
+
]
|
|
619
|
+
if not all_with_src:
|
|
620
|
+
raise ValueError(
|
|
621
|
+
f"Refusing to update: stale image marker bound to '{bound_src}'."
|
|
622
|
+
)
|
|
623
|
+
else:
|
|
624
|
+
raise ValueError(
|
|
625
|
+
f"Refusing to update: reused or duplicate image marker for '{bound_src}'."
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
consumed_remote_indices.add(matched_idx)
|
|
629
|
+
remote_img = remote_images[matched_idx]
|
|
540
630
|
source, alt, href = (
|
|
541
631
|
submitted.get("src"),
|
|
542
632
|
submitted.get("alt"),
|
|
543
633
|
submitted.get("href"),
|
|
544
634
|
)
|
|
545
|
-
submitted.update(
|
|
635
|
+
submitted.update(remote_img)
|
|
636
|
+
submitted.update(marker["attrs"])
|
|
546
637
|
submitted.update(
|
|
547
638
|
{"src": source, "alt": alt, "href": href, "isProcessing": False}
|
|
548
639
|
)
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
640
|
+
|
|
641
|
+
elif marker is not None and marker.get("version") == 1:
|
|
642
|
+
sub_src = submitted.get("src")
|
|
643
|
+
matches = [
|
|
644
|
+
idx
|
|
645
|
+
for idx, r_img in enumerate(remote_images)
|
|
646
|
+
if r_img.get("src") == sub_src
|
|
647
|
+
and idx not in consumed_remote_indices
|
|
648
|
+
]
|
|
649
|
+
if len(matches) == 1:
|
|
650
|
+
matched_idx = matches[0]
|
|
651
|
+
consumed_remote_indices.add(matched_idx)
|
|
652
|
+
remote_img = remote_images[matched_idx]
|
|
653
|
+
source, alt, href = (
|
|
654
|
+
submitted.get("src"),
|
|
655
|
+
submitted.get("alt"),
|
|
656
|
+
submitted.get("href"),
|
|
657
|
+
)
|
|
658
|
+
submitted.update(remote_img)
|
|
659
|
+
submitted.update(marker["attrs"])
|
|
660
|
+
submitted.update(
|
|
661
|
+
{"src": source, "alt": alt, "href": href, "isProcessing": False}
|
|
662
|
+
)
|
|
663
|
+
elif not allow_image_replacement:
|
|
664
|
+
raise ValueError(
|
|
665
|
+
"Refusing to update: image cannot be matched uniquely; set allow_image_replacement=True to proceed."
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
else:
|
|
669
|
+
if not allow_image_replacement:
|
|
670
|
+
raise ValueError(
|
|
671
|
+
"Refusing to update: image cannot be matched uniquely; set allow_image_replacement=True to proceed."
|
|
672
|
+
)
|
|
553
673
|
|
|
554
674
|
_, submitted_unsupported = document_to_markdown(post.draft_body)
|
|
555
675
|
|
|
@@ -597,7 +717,7 @@ class Api:
|
|
|
597
717
|
if tags_list:
|
|
598
718
|
tags_result = self.add_tags_to_post(draft_id, tags_list)
|
|
599
719
|
|
|
600
|
-
|
|
720
|
+
result_payload = {
|
|
601
721
|
"action": "update",
|
|
602
722
|
"draft_id": draft_id,
|
|
603
723
|
"dry_run": dry_run,
|
|
@@ -606,7 +726,14 @@ class Api:
|
|
|
606
726
|
"draft": updated_draft,
|
|
607
727
|
"tags": tags_result,
|
|
608
728
|
"unsupported_nodes": unsupported_nodes,
|
|
729
|
+
"conflict": conflict_detected,
|
|
730
|
+
"mismatches": mismatches,
|
|
731
|
+
"expected_revision": target_expected_rev,
|
|
732
|
+
"current_revision": current_rev,
|
|
609
733
|
}
|
|
734
|
+
if override_enabled and conflict_detected:
|
|
735
|
+
result_payload["override"] = True
|
|
736
|
+
return result_payload
|
|
610
737
|
|
|
611
738
|
def delete_draft(self, draft_id):
|
|
612
739
|
"""
|
substack/cli.py
CHANGED
|
@@ -10,7 +10,11 @@ import yaml
|
|
|
10
10
|
from dotenv import load_dotenv
|
|
11
11
|
|
|
12
12
|
from substack import Api, __version__
|
|
13
|
-
from substack.exceptions import
|
|
13
|
+
from substack.exceptions import (
|
|
14
|
+
DraftConflictError,
|
|
15
|
+
SubstackAPIException,
|
|
16
|
+
SubstackRequestException,
|
|
17
|
+
)
|
|
14
18
|
from substack.post import Post
|
|
15
19
|
|
|
16
20
|
|
|
@@ -146,6 +150,14 @@ def _redact(message):
|
|
|
146
150
|
|
|
147
151
|
|
|
148
152
|
def _error_payload(exc):
|
|
153
|
+
if isinstance(exc, DraftConflictError):
|
|
154
|
+
return {
|
|
155
|
+
"error": {
|
|
156
|
+
"type": "conflict_error",
|
|
157
|
+
"message": _redact(exc.message),
|
|
158
|
+
"mismatches": exc.mismatches,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
149
161
|
if isinstance(exc, SubstackAPIException):
|
|
150
162
|
return {
|
|
151
163
|
"error": {
|
|
@@ -314,6 +326,8 @@ def _drafts_update(api, args):
|
|
|
314
326
|
raise CLIUsageError("--yes is required when using --allow-unsupported-change")
|
|
315
327
|
if args.allow_image_replacement and not args.yes:
|
|
316
328
|
raise CLIUsageError("--yes is required when using --allow-image-replacement")
|
|
329
|
+
if args.allow_conflict and not args.yes:
|
|
330
|
+
raise CLIUsageError("--yes is required when using --allow-conflict")
|
|
317
331
|
|
|
318
332
|
if not args.yes and (args.json_output or not sys.stdin.isatty()):
|
|
319
333
|
raise CLIUsageError("--yes is required in non-interactive or JSON mode")
|
|
@@ -354,13 +368,25 @@ def _drafts_update(api, args):
|
|
|
354
368
|
dry_run=args.dry_run,
|
|
355
369
|
allow_unsupported_change=args.allow_unsupported_change,
|
|
356
370
|
allow_image_replacement=args.allow_image_replacement,
|
|
371
|
+
allow_conflict=args.allow_conflict,
|
|
357
372
|
)
|
|
358
373
|
|
|
359
374
|
if args.json_output:
|
|
360
375
|
_print_json(result)
|
|
361
376
|
else:
|
|
362
377
|
status = "Dry-run updated" if args.dry_run else "Updated"
|
|
363
|
-
|
|
378
|
+
if result.get("conflict"):
|
|
379
|
+
print(f"Conflict detected in draft {args.draft_id}:")
|
|
380
|
+
for mismatch in result.get("mismatches", []):
|
|
381
|
+
print(f" - {mismatch}")
|
|
382
|
+
if result.get("dry_run"):
|
|
383
|
+
print(
|
|
384
|
+
f"Dry-run preview completed for draft {args.draft_id} with conflicts."
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
print(f"{status} draft {args.draft_id} (override applied)")
|
|
388
|
+
else:
|
|
389
|
+
print(f"{status} draft {args.draft_id}")
|
|
364
390
|
|
|
365
391
|
|
|
366
392
|
def _drafts_export(api, args):
|
|
@@ -381,6 +407,8 @@ def _drafts_export(api, args):
|
|
|
381
407
|
"markdown": markdown,
|
|
382
408
|
"unsupported_nodes": result["unsupported_nodes"],
|
|
383
409
|
}
|
|
410
|
+
if "revision" in result and result["revision"] is not None:
|
|
411
|
+
payload["revision"] = result["revision"]
|
|
384
412
|
if args.json_output:
|
|
385
413
|
_print_json(payload)
|
|
386
414
|
elif output_path is not None:
|
|
@@ -517,6 +545,13 @@ def _build_parser():
|
|
|
517
545
|
drafts_update.add_argument("--dry-run", action="store_true")
|
|
518
546
|
drafts_update.add_argument("--allow-unsupported-change", action="store_true")
|
|
519
547
|
drafts_update.add_argument("--allow-image-replacement", action="store_true")
|
|
548
|
+
drafts_update.add_argument(
|
|
549
|
+
"--allow-conflict",
|
|
550
|
+
"--force",
|
|
551
|
+
action="store_true",
|
|
552
|
+
dest="allow_conflict",
|
|
553
|
+
help="Allow updating draft even if remote revision has changed or revision metadata is missing.",
|
|
554
|
+
)
|
|
520
555
|
drafts_update.add_argument("--yes", action="store_true")
|
|
521
556
|
drafts_update.set_defaults(handler=_drafts_update)
|
|
522
557
|
|
substack/exceptions.py
CHANGED
|
@@ -12,7 +12,13 @@ def _redact_message(text: str) -> str:
|
|
|
12
12
|
return text
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
class
|
|
15
|
+
class SubstackException(Exception):
|
|
16
|
+
"""Base exception for all python-substack exceptions."""
|
|
17
|
+
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SubstackAPIException(SubstackException):
|
|
16
22
|
def __init__(self, status_code, text):
|
|
17
23
|
text = _redact_message(text)
|
|
18
24
|
try:
|
|
@@ -32,7 +38,7 @@ class SubstackAPIException(Exception):
|
|
|
32
38
|
return f"APIError(code={self.status_code}): {self.message}"
|
|
33
39
|
|
|
34
40
|
|
|
35
|
-
class SubstackRequestException(
|
|
41
|
+
class SubstackRequestException(SubstackException):
|
|
36
42
|
def __init__(self, message):
|
|
37
43
|
self.message = _redact_message(message)
|
|
38
44
|
|
|
@@ -42,3 +48,23 @@ class SubstackRequestException(Exception):
|
|
|
42
48
|
|
|
43
49
|
class SectionNotExistsException(SubstackRequestException):
|
|
44
50
|
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DraftConflictError(ValueError, SubstackException):
|
|
54
|
+
"""Raised when an update conflicts with the current remote draft state or lacks revision metadata."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
message: str,
|
|
59
|
+
mismatches: list = None,
|
|
60
|
+
expected_revision: dict = None,
|
|
61
|
+
current_revision: dict = None,
|
|
62
|
+
):
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.message = message
|
|
65
|
+
self.mismatches = mismatches or []
|
|
66
|
+
self.expected_revision = expected_revision
|
|
67
|
+
self.current_revision = current_revision
|
|
68
|
+
|
|
69
|
+
def __str__(self):
|
|
70
|
+
return self.message
|
substack/mdexport.py
CHANGED
|
@@ -58,18 +58,19 @@ def _marker(node, unsupported):
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
def _image_marker(attrs):
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
101
|
-
raise ValueError("Corrupt image marker format")
|
|
102
|
-
return None
|
|
103
|
-
encoded = match.group(1)
|
|
104
|
-
encoded += "=" * (-len(encoded) % 4)
|
|
105
|
-
try:
|
|
106
|
-
|
|
107
|
-
except Exception as exc:
|
|
108
|
-
raise ValueError("Invalid image marker") from exc
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
raise ValueError("Invalid image marker
|
|
113
|
-
|
|
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(
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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):
|
substack/revision.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from markdown_it import MarkdownIt
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def compute_draft_revision(draft: Dict[str, Any]) -> Dict[str, Any]:
|
|
11
|
+
"""Compute deterministic revision and fingerprint metadata from a Substack draft dict."""
|
|
12
|
+
draft_id = draft.get("id")
|
|
13
|
+
title = (
|
|
14
|
+
draft.get("draft_title") if "draft_title" in draft else draft.get("title", "")
|
|
15
|
+
)
|
|
16
|
+
subtitle = (
|
|
17
|
+
draft.get("draft_subtitle")
|
|
18
|
+
if "draft_subtitle" in draft
|
|
19
|
+
else draft.get("subtitle", "")
|
|
20
|
+
)
|
|
21
|
+
audience = draft.get("audience") or "everyone"
|
|
22
|
+
write_comment_permissions = draft.get("write_comment_permissions") or "everyone"
|
|
23
|
+
draft_section_id = (
|
|
24
|
+
draft.get("draft_section_id")
|
|
25
|
+
if "draft_section_id" in draft
|
|
26
|
+
else draft.get("section_id")
|
|
27
|
+
)
|
|
28
|
+
slug = draft.get("slug") or ""
|
|
29
|
+
updated_at = draft.get("draft_updated_at") or draft.get("updated_at")
|
|
30
|
+
|
|
31
|
+
raw_body = draft.get("draft_body")
|
|
32
|
+
if isinstance(raw_body, str):
|
|
33
|
+
try:
|
|
34
|
+
body_obj = json.loads(raw_body)
|
|
35
|
+
except Exception:
|
|
36
|
+
body_obj = raw_body
|
|
37
|
+
else:
|
|
38
|
+
body_obj = raw_body
|
|
39
|
+
|
|
40
|
+
if isinstance(body_obj, (dict, list)):
|
|
41
|
+
body_canonical = json.dumps(
|
|
42
|
+
body_obj, ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
body_canonical = str(body_obj or "")
|
|
46
|
+
|
|
47
|
+
body_hash = hashlib.sha256(body_canonical.encode("utf-8")).hexdigest()
|
|
48
|
+
|
|
49
|
+
fingerprint_components = {
|
|
50
|
+
"draft_id": draft_id,
|
|
51
|
+
"body_hash": body_hash,
|
|
52
|
+
"title": title or "",
|
|
53
|
+
"subtitle": subtitle or "",
|
|
54
|
+
"audience": audience,
|
|
55
|
+
"write_comment_permissions": write_comment_permissions,
|
|
56
|
+
"draft_section_id": draft_section_id,
|
|
57
|
+
"slug": slug,
|
|
58
|
+
}
|
|
59
|
+
fingerprint_bytes = json.dumps(
|
|
60
|
+
fingerprint_components,
|
|
61
|
+
ensure_ascii=False,
|
|
62
|
+
separators=(",", ":"),
|
|
63
|
+
sort_keys=True,
|
|
64
|
+
).encode("utf-8")
|
|
65
|
+
revision_hash = hashlib.sha256(fingerprint_bytes).hexdigest()
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"version": 1,
|
|
69
|
+
"draft_id": draft_id,
|
|
70
|
+
"revision": revision_hash,
|
|
71
|
+
"body_hash": body_hash,
|
|
72
|
+
"title": title or "",
|
|
73
|
+
"subtitle": subtitle or "",
|
|
74
|
+
"audience": audience,
|
|
75
|
+
"write_comment_permissions": write_comment_permissions,
|
|
76
|
+
"draft_section_id": draft_section_id,
|
|
77
|
+
"slug": slug,
|
|
78
|
+
"updated_at": str(updated_at) if updated_at else None,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_revision_marker(revision_info: Dict[str, Any]) -> str:
|
|
83
|
+
"""Format revision metadata as a versioned HTML comment marker."""
|
|
84
|
+
payload = json.dumps(
|
|
85
|
+
revision_info, ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
|
86
|
+
).encode("utf-8")
|
|
87
|
+
encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
|
|
88
|
+
return f"<!-- python-substack-revision:v1 {encoded} -->"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_revision_marker(markdown: str) -> Optional[Dict[str, Any]]:
|
|
92
|
+
"""
|
|
93
|
+
Parse a python-substack-revision:v1 comment marker from markdown content.
|
|
94
|
+
|
|
95
|
+
Only top-level HTML comment blocks are considered; comments inside code blocks
|
|
96
|
+
are ignored.
|
|
97
|
+
|
|
98
|
+
Returns the parsed revision dictionary, or None if no marker is found.
|
|
99
|
+
Raises ValueError if an attempted marker is corrupt, malformed, unsupported, or duplicated.
|
|
100
|
+
"""
|
|
101
|
+
md = MarkdownIt()
|
|
102
|
+
tokens = md.parse(markdown)
|
|
103
|
+
|
|
104
|
+
found_markers = []
|
|
105
|
+
marker_pattern = re.compile(
|
|
106
|
+
r"^<!--\s*python-substack-revision:v([0-9]+)\s+([A-Za-z0-9_-]+=*)\s*-->$"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
for token in tokens:
|
|
110
|
+
if token.type == "html_block":
|
|
111
|
+
clean = token.content.strip()
|
|
112
|
+
match = marker_pattern.match(clean)
|
|
113
|
+
if match:
|
|
114
|
+
found_markers.append(match.groups())
|
|
115
|
+
elif "python-substack-revision:" in clean:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
"Malformed revision marker: corrupt comment format or invalid characters"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if not found_markers:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
if len(found_markers) > 1:
|
|
124
|
+
raise ValueError("Malformed revision marker: multiple revision markers found")
|
|
125
|
+
|
|
126
|
+
version_str, encoded = found_markers[0]
|
|
127
|
+
if version_str != "1":
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"Malformed revision marker: unsupported version '{version_str}'"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
pad = len(encoded) % 4
|
|
133
|
+
if pad:
|
|
134
|
+
encoded += "=" * (4 - pad)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
raw = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
raise ValueError("Malformed revision marker: invalid base64 encoding") from exc
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
data = json.loads(raw.decode("utf-8"))
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
raise ValueError("Malformed revision marker: invalid JSON payload") from exc
|
|
145
|
+
|
|
146
|
+
if not isinstance(data, dict):
|
|
147
|
+
raise ValueError("Malformed revision marker: payload must be a JSON object")
|
|
148
|
+
|
|
149
|
+
if not data.get("revision") or not isinstance(data.get("revision"), str):
|
|
150
|
+
raise ValueError(
|
|
151
|
+
"Malformed revision marker: missing or empty required 'revision' field"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return data
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def compare_draft_revisions(
|
|
158
|
+
expected: Dict[str, Any], current: Dict[str, Any]
|
|
159
|
+
) -> List[str]:
|
|
160
|
+
"""
|
|
161
|
+
Compare expected revision metadata with current remote draft revision metadata.
|
|
162
|
+
|
|
163
|
+
Returns a list of human-readable, actionable mismatch descriptions.
|
|
164
|
+
If the list is empty, there is no conflict.
|
|
165
|
+
"""
|
|
166
|
+
mismatches: List[str] = []
|
|
167
|
+
|
|
168
|
+
if (
|
|
169
|
+
expected.get("draft_id") is not None
|
|
170
|
+
and current.get("draft_id") is not None
|
|
171
|
+
and str(expected.get("draft_id")) != str(current.get("draft_id"))
|
|
172
|
+
):
|
|
173
|
+
mismatches.append(
|
|
174
|
+
f"Draft ID mismatch: expected {expected.get('draft_id')}, got {current.get('draft_id')}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if (
|
|
178
|
+
expected.get("body_hash")
|
|
179
|
+
and current.get("body_hash")
|
|
180
|
+
and expected.get("body_hash") != current.get("body_hash")
|
|
181
|
+
):
|
|
182
|
+
mismatches.append("Remote draft body has changed since export")
|
|
183
|
+
|
|
184
|
+
if (
|
|
185
|
+
expected.get("title") is not None
|
|
186
|
+
and current.get("title") is not None
|
|
187
|
+
and expected.get("title") != current.get("title")
|
|
188
|
+
):
|
|
189
|
+
mismatches.append(
|
|
190
|
+
f"Remote title changed: expected '{expected.get('title')}', got '{current.get('title')}'"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if (
|
|
194
|
+
expected.get("subtitle") is not None
|
|
195
|
+
and current.get("subtitle") is not None
|
|
196
|
+
and expected.get("subtitle") != current.get("subtitle")
|
|
197
|
+
):
|
|
198
|
+
mismatches.append(
|
|
199
|
+
f"Remote subtitle changed: expected '{expected.get('subtitle')}', got '{current.get('subtitle')}'"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if (
|
|
203
|
+
expected.get("audience")
|
|
204
|
+
and current.get("audience")
|
|
205
|
+
and expected.get("audience") != current.get("audience")
|
|
206
|
+
):
|
|
207
|
+
mismatches.append(
|
|
208
|
+
f"Remote audience changed: expected '{expected.get('audience')}', got '{current.get('audience')}'"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if (
|
|
212
|
+
expected.get("write_comment_permissions")
|
|
213
|
+
and current.get("write_comment_permissions")
|
|
214
|
+
and expected.get("write_comment_permissions")
|
|
215
|
+
!= current.get("write_comment_permissions")
|
|
216
|
+
):
|
|
217
|
+
mismatches.append(
|
|
218
|
+
f"Remote write_comment_permissions changed: expected '{expected.get('write_comment_permissions')}', got '{current.get('write_comment_permissions')}'"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if expected.get("draft_section_id") != current.get("draft_section_id"):
|
|
222
|
+
mismatches.append(
|
|
223
|
+
f"Remote section changed: expected {expected.get('draft_section_id')}, got {current.get('draft_section_id')}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if (
|
|
227
|
+
expected.get("slug") is not None
|
|
228
|
+
and current.get("slug") is not None
|
|
229
|
+
and expected.get("slug") != current.get("slug")
|
|
230
|
+
):
|
|
231
|
+
mismatches.append(
|
|
232
|
+
f"Remote slug changed: expected '{expected.get('slug')}', got '{current.get('slug')}'"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
expected.get("updated_at")
|
|
237
|
+
and current.get("updated_at")
|
|
238
|
+
and expected.get("updated_at") != current.get("updated_at")
|
|
239
|
+
):
|
|
240
|
+
mismatches.append(
|
|
241
|
+
f"Remote update timestamp changed: expected '{expected.get('updated_at')}', got '{current.get('updated_at')}'"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if (
|
|
245
|
+
expected.get("revision")
|
|
246
|
+
and current.get("revision")
|
|
247
|
+
and expected.get("revision") != current.get("revision")
|
|
248
|
+
and not mismatches
|
|
249
|
+
):
|
|
250
|
+
mismatches.append(
|
|
251
|
+
f"Remote revision fingerprint mismatch: expected '{expected.get('revision')}', got '{current.get('revision')}'"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return mismatches
|
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|