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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-substack
3
- Version: 0.1.22
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)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.1.22"
3
+ version = "0.1.23"
4
4
  description = "A Python wrapper around the Substack API."
5
5
  authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
6
6
  license = "MIT"
@@ -3,7 +3,7 @@
3
3
  __author__ = "Paolo Mazza"
4
4
  __email__ = "mazzapaolo2019@gmail.com"
5
5
  __license__ = "MIT License"
6
- __version__ = "0.1.21"
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