python-substack 0.1.22__tar.gz → 0.1.23__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.1.22 → python_substack-0.1.23}/PKG-INFO +16 -1
- {python_substack-0.1.22 → python_substack-0.1.23}/README.md +15 -0
- {python_substack-0.1.22 → python_substack-0.1.23}/pyproject.toml +1 -1
- {python_substack-0.1.22 → python_substack-0.1.23}/substack/__init__.py +1 -1
- {python_substack-0.1.22 → python_substack-0.1.23}/substack/post.py +202 -0
- {python_substack-0.1.22 → python_substack-0.1.23}/LICENSE +0 -0
- {python_substack-0.1.22 → python_substack-0.1.23}/substack/api.py +0 -0
- {python_substack-0.1.22 → python_substack-0.1.23}/substack/exceptions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.23
|
|
4
4
|
Summary: A Python wrapper around the Substack API.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -180,6 +180,21 @@ This is a paragraph with **bold** and *italic* text.
|
|
|
180
180
|
"""
|
|
181
181
|
post.from_markdown(markdown_content, api=api)
|
|
182
182
|
|
|
183
|
+
# Markdown footnotes are supported too. References become inline anchors and
|
|
184
|
+
# definitions become footnote blocks, numbered by order of first appearance.
|
|
185
|
+
# Labels can be numbers or names (e.g. [^1] or [^source]).
|
|
186
|
+
footnote_markdown = """
|
|
187
|
+
A claim that needs support.[^1] Another, with a named label.[^source]
|
|
188
|
+
|
|
189
|
+
[^1]: The supporting detail, with a [link](https://example.com).
|
|
190
|
+
[^source]: Author, *Title* (2025).
|
|
191
|
+
"""
|
|
192
|
+
post.from_markdown(footnote_markdown, api=api)
|
|
193
|
+
|
|
194
|
+
# Or build footnotes manually:
|
|
195
|
+
post.paragraph(content=[{"content": "Some claim."}]).footnote_anchor(1)
|
|
196
|
+
post.footnote(1, "The note text, with **formatting** allowed.")
|
|
197
|
+
|
|
183
198
|
draft = api.post_draft(post.get_draft())
|
|
184
199
|
|
|
185
200
|
# set section (can only be done after first posting the draft)
|
|
@@ -156,6 +156,21 @@ This is a paragraph with **bold** and *italic* text.
|
|
|
156
156
|
"""
|
|
157
157
|
post.from_markdown(markdown_content, api=api)
|
|
158
158
|
|
|
159
|
+
# Markdown footnotes are supported too. References become inline anchors and
|
|
160
|
+
# definitions become footnote blocks, numbered by order of first appearance.
|
|
161
|
+
# Labels can be numbers or names (e.g. [^1] or [^source]).
|
|
162
|
+
footnote_markdown = """
|
|
163
|
+
A claim that needs support.[^1] Another, with a named label.[^source]
|
|
164
|
+
|
|
165
|
+
[^1]: The supporting detail, with a [link](https://example.com).
|
|
166
|
+
[^source]: Author, *Title* (2025).
|
|
167
|
+
"""
|
|
168
|
+
post.from_markdown(footnote_markdown, api=api)
|
|
169
|
+
|
|
170
|
+
# Or build footnotes manually:
|
|
171
|
+
post.paragraph(content=[{"content": "Some claim."}]).footnote_anchor(1)
|
|
172
|
+
post.footnote(1, "The note text, with **formatting** allowed.")
|
|
173
|
+
|
|
159
174
|
draft = api.post_draft(post.get_draft())
|
|
160
175
|
|
|
161
176
|
# set section (can only be done after first posting the draft)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
__author__ = "Paolo Mazza"
|
|
4
4
|
__email__ = "mazzapaolo2019@gmail.com"
|
|
5
5
|
__license__ = "MIT License"
|
|
6
|
-
__version__ = "0.1.
|
|
6
|
+
__version__ = "0.1.23"
|
|
7
7
|
__url__ = "https://github.com/ma2za/python-substack"
|
|
8
8
|
__download_url__ = "https://pypi.python.org/pypi/python-substack"
|
|
9
9
|
__description__ = "A Python wrapper around the Substack API"
|
|
@@ -12,6 +12,10 @@ __all__ = ["Post", "parse_inline", "tokens_to_text_nodes"]
|
|
|
12
12
|
|
|
13
13
|
from substack.exceptions import SectionNotExistsException
|
|
14
14
|
|
|
15
|
+
# Markdown footnotes: ``text.[^label]`` references and ``[^label]: definition`` lines.
|
|
16
|
+
FOOTNOTE_REFERENCE_PATTERN = re.compile(r"\[\^([^\]]+)\]")
|
|
17
|
+
FOOTNOTE_DEFINITION_PATTERN = re.compile(r"^\[\^([^\]]+)\]:\s?(.*)$")
|
|
18
|
+
|
|
15
19
|
|
|
16
20
|
def tokens_to_text_nodes(tokens: List[Dict]) -> List[Dict]:
|
|
17
21
|
"""Convert parse_inline() tokens to ProseMirror text nodes.
|
|
@@ -543,6 +547,186 @@ class Post:
|
|
|
543
547
|
|
|
544
548
|
return self
|
|
545
549
|
|
|
550
|
+
def footnote_anchor(self, number: int):
|
|
551
|
+
"""
|
|
552
|
+
|
|
553
|
+
Add an inline footnote reference (the superscript marker) to the last block.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
number: The footnote number this anchor points to.
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
Self for method chaining.
|
|
560
|
+
|
|
561
|
+
"""
|
|
562
|
+
content = self.draft_body["content"][-1].get("content", [])
|
|
563
|
+
content += [{"type": "footnoteAnchor", "attrs": {"number": number}}]
|
|
564
|
+
self.draft_body["content"][-1]["content"] = content
|
|
565
|
+
return self
|
|
566
|
+
|
|
567
|
+
def footnote(self, number: int, content=None):
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
Append a footnote block (the note shown at the foot of the post).
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
number: The footnote number, matching a footnote_anchor.
|
|
574
|
+
content: Text string or list of inline token dicts. A plain string is
|
|
575
|
+
parsed for inline Markdown and may contain blank-line-separated
|
|
576
|
+
paragraphs; a parse_inline() token list or a list of ready text
|
|
577
|
+
nodes is also accepted (single paragraph).
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
Self for method chaining.
|
|
581
|
+
|
|
582
|
+
"""
|
|
583
|
+
paragraphs: List[Dict] = []
|
|
584
|
+
if isinstance(content, str):
|
|
585
|
+
# Blank lines separate paragraphs within the footnote.
|
|
586
|
+
for chunk in re.split(r"\n\s*\n", content):
|
|
587
|
+
chunk = chunk.strip()
|
|
588
|
+
if chunk:
|
|
589
|
+
paragraphs.append(
|
|
590
|
+
{"type": "paragraph", "content": tokens_to_text_nodes(parse_inline(chunk))}
|
|
591
|
+
)
|
|
592
|
+
elif isinstance(content, list):
|
|
593
|
+
# Accept either parse_inline tokens ({"content": ...}) or text nodes.
|
|
594
|
+
if content and content[0].get("type") == "text":
|
|
595
|
+
text_nodes = content
|
|
596
|
+
else:
|
|
597
|
+
text_nodes = tokens_to_text_nodes(content)
|
|
598
|
+
paragraphs.append({"type": "paragraph", "content": text_nodes})
|
|
599
|
+
|
|
600
|
+
if not paragraphs:
|
|
601
|
+
paragraphs = [{"type": "paragraph", "content": []}]
|
|
602
|
+
|
|
603
|
+
node: Dict = {
|
|
604
|
+
"type": "footnote",
|
|
605
|
+
"attrs": {"number": number},
|
|
606
|
+
"content": paragraphs,
|
|
607
|
+
}
|
|
608
|
+
self.draft_body["content"] = self.draft_body.get("content", []) + [node]
|
|
609
|
+
return self
|
|
610
|
+
|
|
611
|
+
@staticmethod
|
|
612
|
+
def _extract_footnote_definitions(markdown_content: str):
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
Pull ``[^label]: definition`` lines out of the Markdown.
|
|
616
|
+
|
|
617
|
+
Definitions may wrap onto indented continuation lines and may contain
|
|
618
|
+
multiple paragraphs (blank line followed by an indented block). Returns
|
|
619
|
+
the body with definitions removed plus a {label: definition_text} mapping,
|
|
620
|
+
where paragraphs are separated by a blank line.
|
|
621
|
+
|
|
622
|
+
"""
|
|
623
|
+
lines = markdown_content.split("\n")
|
|
624
|
+
body_lines: List[str] = []
|
|
625
|
+
definitions: Dict[str, str] = {}
|
|
626
|
+
in_code_fence = False
|
|
627
|
+
i = 0
|
|
628
|
+
while i < len(lines):
|
|
629
|
+
# Track fenced code blocks so footnote-like lines inside them are
|
|
630
|
+
# left untouched.
|
|
631
|
+
if lines[i].lstrip().startswith("```"):
|
|
632
|
+
in_code_fence = not in_code_fence
|
|
633
|
+
body_lines.append(lines[i])
|
|
634
|
+
i += 1
|
|
635
|
+
continue
|
|
636
|
+
match = None if in_code_fence else FOOTNOTE_DEFINITION_PATTERN.match(lines[i])
|
|
637
|
+
if match:
|
|
638
|
+
label, first = match.group(1), match.group(2)
|
|
639
|
+
paragraphs: List[str] = []
|
|
640
|
+
current = [first.strip()] if first.strip() else []
|
|
641
|
+
i += 1
|
|
642
|
+
while i < len(lines):
|
|
643
|
+
line = lines[i]
|
|
644
|
+
if line.strip() == "":
|
|
645
|
+
# A blank line stays in the footnote only if the next
|
|
646
|
+
# non-empty line is indented (a further paragraph).
|
|
647
|
+
nxt = i + 1
|
|
648
|
+
if (
|
|
649
|
+
nxt < len(lines)
|
|
650
|
+
and lines[nxt].strip()
|
|
651
|
+
and lines[nxt][:1] in (" ", "\t")
|
|
652
|
+
):
|
|
653
|
+
if current:
|
|
654
|
+
paragraphs.append(" ".join(current))
|
|
655
|
+
current = []
|
|
656
|
+
i += 1
|
|
657
|
+
continue
|
|
658
|
+
break
|
|
659
|
+
if line[:1] in (" ", "\t"):
|
|
660
|
+
current.append(line.strip())
|
|
661
|
+
i += 1
|
|
662
|
+
else:
|
|
663
|
+
break
|
|
664
|
+
if current:
|
|
665
|
+
paragraphs.append(" ".join(current))
|
|
666
|
+
definitions[label] = "\n\n".join(paragraphs)
|
|
667
|
+
else:
|
|
668
|
+
body_lines.append(lines[i])
|
|
669
|
+
i += 1
|
|
670
|
+
return "\n".join(body_lines), definitions
|
|
671
|
+
|
|
672
|
+
@staticmethod
|
|
673
|
+
def _number_footnotes(markdown_content: str, definitions: Dict[str, str]):
|
|
674
|
+
"""Number footnotes by order of first inline reference in the body."""
|
|
675
|
+
order: List[str] = []
|
|
676
|
+
for match in FOOTNOTE_REFERENCE_PATTERN.finditer(markdown_content):
|
|
677
|
+
label = match.group(1)
|
|
678
|
+
if label in definitions and label not in order:
|
|
679
|
+
order.append(label)
|
|
680
|
+
# Defined-but-unreferenced footnotes go last, in definition order.
|
|
681
|
+
for label in definitions:
|
|
682
|
+
if label not in order:
|
|
683
|
+
order.append(label)
|
|
684
|
+
return {label: index + 1 for index, label in enumerate(order)}
|
|
685
|
+
|
|
686
|
+
def _inject_footnote_anchors(self, node: Dict, numbers_by_label: Dict[str, int]):
|
|
687
|
+
"""Recursively replace ``[^label]`` in text nodes with footnoteAnchor nodes."""
|
|
688
|
+
# Never rewrite the contents of a code block.
|
|
689
|
+
if node.get("type") == "codeBlock":
|
|
690
|
+
return
|
|
691
|
+
content = node.get("content")
|
|
692
|
+
if not isinstance(content, list):
|
|
693
|
+
return
|
|
694
|
+
new_content: List[Dict] = []
|
|
695
|
+
for child in content:
|
|
696
|
+
text = child.get("text", "")
|
|
697
|
+
has_code_mark = any(
|
|
698
|
+
mark.get("type") == "code" for mark in (child.get("marks") or [])
|
|
699
|
+
)
|
|
700
|
+
if (
|
|
701
|
+
child.get("type") == "text"
|
|
702
|
+
and not has_code_mark
|
|
703
|
+
and FOOTNOTE_REFERENCE_PATTERN.search(text)
|
|
704
|
+
):
|
|
705
|
+
marks = child.get("marks")
|
|
706
|
+
last = 0
|
|
707
|
+
for match in FOOTNOTE_REFERENCE_PATTERN.finditer(text):
|
|
708
|
+
label = match.group(1)
|
|
709
|
+
if label not in numbers_by_label:
|
|
710
|
+
continue # Unknown label: leave the literal text in place.
|
|
711
|
+
if match.start() > last:
|
|
712
|
+
segment = {"type": "text", "text": text[last:match.start()]}
|
|
713
|
+
if marks:
|
|
714
|
+
segment["marks"] = marks
|
|
715
|
+
new_content.append(segment)
|
|
716
|
+
new_content.append(
|
|
717
|
+
{"type": "footnoteAnchor", "attrs": {"number": numbers_by_label[label]}}
|
|
718
|
+
)
|
|
719
|
+
last = match.end()
|
|
720
|
+
if last < len(text):
|
|
721
|
+
segment = {"type": "text", "text": text[last:]}
|
|
722
|
+
if marks:
|
|
723
|
+
segment["marks"] = marks
|
|
724
|
+
new_content.append(segment)
|
|
725
|
+
else:
|
|
726
|
+
self._inject_footnote_anchors(child, numbers_by_label)
|
|
727
|
+
new_content.append(child)
|
|
728
|
+
node["content"] = new_content
|
|
729
|
+
|
|
546
730
|
def from_markdown(self, markdown_content: str, api=None):
|
|
547
731
|
"""
|
|
548
732
|
Parse Markdown content and add it to the post.
|
|
@@ -559,6 +743,10 @@ class Post:
|
|
|
559
743
|
- Ordered lists: Lines starting with '1.', '2.', etc.
|
|
560
744
|
- Horizontal rules: Lines with ---, ***, or ___
|
|
561
745
|
- Inline formatting: **bold**, *italic*, ***bold+italic***, `code`, ~~strikethrough~~
|
|
746
|
+
- Footnotes: ``text.[^label]`` references plus ``[^label]: definition``
|
|
747
|
+
lines. References become inline anchors and definitions become
|
|
748
|
+
footnote blocks, numbered by order of first appearance. Labels may be
|
|
749
|
+
numbers or names (e.g. ``[^1]`` or ``[^agi-book]``).
|
|
562
750
|
|
|
563
751
|
Args:
|
|
564
752
|
markdown_content: Markdown string to parse and add to the post.
|
|
@@ -572,6 +760,13 @@ class Post:
|
|
|
572
760
|
>>> post = Post("Title", "Subtitle", user_id)
|
|
573
761
|
>>> post.from_markdown("# Heading\\n\\nThis is **bold** text with [a link](https://example.com).")
|
|
574
762
|
"""
|
|
763
|
+
# Footnotes: extract ``[^label]: ...`` definitions and number them by
|
|
764
|
+
# order of first reference before parsing the rest of the body.
|
|
765
|
+
markdown_content, footnote_definitions = self._extract_footnote_definitions(
|
|
766
|
+
markdown_content
|
|
767
|
+
)
|
|
768
|
+
footnote_numbers = self._number_footnotes(markdown_content, footnote_definitions)
|
|
769
|
+
|
|
575
770
|
lines = markdown_content.split("\n")
|
|
576
771
|
blocks = []
|
|
577
772
|
current_block: List[str] = []
|
|
@@ -844,4 +1039,11 @@ class Post:
|
|
|
844
1039
|
tokens = parse_inline(text_content)
|
|
845
1040
|
self.add({"type": "paragraph", "content": tokens})
|
|
846
1041
|
|
|
1042
|
+
# Footnotes: turn ``[^label]`` references into inline anchors, then append
|
|
1043
|
+
# the footnote blocks in numbered order.
|
|
1044
|
+
if footnote_numbers:
|
|
1045
|
+
self._inject_footnote_anchors(self.draft_body, footnote_numbers)
|
|
1046
|
+
for label, number in sorted(footnote_numbers.items(), key=lambda item: item[1]):
|
|
1047
|
+
self.footnote(number, footnote_definitions[label])
|
|
1048
|
+
|
|
847
1049
|
return self
|
|
File without changes
|
|
File without changes
|
|
File without changes
|