python-substack 0.1.20__tar.gz → 0.1.22__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,24 +1,23 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-substack
3
- Version: 0.1.20
3
+ Version: 0.1.22
4
4
  Summary: A Python wrapper around the Substack API.
5
5
  License: MIT
6
6
  License-File: LICENSE
7
7
  Keywords: substack
8
8
  Author: Paolo Mazza
9
9
  Author-email: mazzapaolo2019@gmail.com
10
- Requires-Python: >=3.9
10
+ Requires-Python: >=3.10,<4.0
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.9
14
13
  Classifier: Programming Language :: Python :: 3.10
15
14
  Classifier: Programming Language :: Python :: 3.11
16
15
  Classifier: Programming Language :: Python :: 3.12
17
16
  Classifier: Programming Language :: Python :: 3.13
18
17
  Classifier: Programming Language :: Python :: 3.14
19
18
  Requires-Dist: PyYAML (>=6.0,<7.0)
20
- Requires-Dist: python-dotenv (>=0.21.0,<0.22.0)
21
- Requires-Dist: requests (>=2.31.0,<3.0.0)
19
+ Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
20
+ Requires-Dist: requests (>=2.32.0,<3.0.0)
22
21
  Project-URL: Homepage, https://github.com/ma2za/python-substack
23
22
  Project-URL: Repository, https://github.com/ma2za/python-substack
24
23
  Description-Content-Type: text/markdown
@@ -38,6 +37,12 @@ You can install python-substack using:
38
37
 
39
38
  $ pip install python-substack
40
39
 
40
+ For the MCP server tools, install the extra dependency set:
41
+
42
+ $ poetry install --with mcp
43
+
44
+ > NOTE: We had to upgrade the package requirements to support Python 3.10 because 3.9 is basically vintage now. If you still run 3.9, please join us in the future (or bring snacks).
45
+
41
46
  ---
42
47
 
43
48
  # Setup
@@ -272,6 +277,22 @@ body:
272
277
  src: "local_image.jpg" # Local images will be uploaded automatically
273
278
  ```
274
279
 
280
+ ## MCP FastMCP server
281
+
282
+ This package now includes a FastMCP server in `substack/mcp_fastmcp.py` with the following tools:
283
+
284
+ - `post_draft_from_markdown(...)`: create draft from markdown, optional tag/add/prepublish/publish, and control send/share_automatically.
285
+ - `put_draft(draft_id, update_payload)`: update draft fields.
286
+ - `add_tags(draft_id, tags)`: add tags to a draft/post.
287
+ - `prepublish_draft(draft_id)`: prepublish a draft.
288
+ - `publish_draft(draft_id, send=True, share_automatically=False)`: publish a draft.
289
+
290
+ Use via stdio transport:
291
+
292
+ ```bash
293
+ python -c "from substack.mcp_fastmcp import main; main()"
294
+ ```
295
+
275
296
  # Contributing
276
297
 
277
298
  Install pre-commit:
@@ -13,6 +13,12 @@ You can install python-substack using:
13
13
 
14
14
  $ pip install python-substack
15
15
 
16
+ For the MCP server tools, install the extra dependency set:
17
+
18
+ $ poetry install --with mcp
19
+
20
+ > NOTE: We had to upgrade the package requirements to support Python 3.10 because 3.9 is basically vintage now. If you still run 3.9, please join us in the future (or bring snacks).
21
+
16
22
  ---
17
23
 
18
24
  # Setup
@@ -247,6 +253,22 @@ body:
247
253
  src: "local_image.jpg" # Local images will be uploaded automatically
248
254
  ```
249
255
 
256
+ ## MCP FastMCP server
257
+
258
+ This package now includes a FastMCP server in `substack/mcp_fastmcp.py` with the following tools:
259
+
260
+ - `post_draft_from_markdown(...)`: create draft from markdown, optional tag/add/prepublish/publish, and control send/share_automatically.
261
+ - `put_draft(draft_id, update_payload)`: update draft fields.
262
+ - `add_tags(draft_id, tags)`: add tags to a draft/post.
263
+ - `prepublish_draft(draft_id)`: prepublish a draft.
264
+ - `publish_draft(draft_id, send=True, share_automatically=False)`: publish a draft.
265
+
266
+ Use via stdio transport:
267
+
268
+ ```bash
269
+ python -c "from substack.mcp_fastmcp import main; main()"
270
+ ```
271
+
250
272
  # Contributing
251
273
 
252
274
  Install pre-commit:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.1.20"
3
+ version = "0.1.22"
4
4
  description = "A Python wrapper around the Substack API."
5
5
  authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
6
6
  license = "MIT"
@@ -16,15 +16,20 @@ homepage = "https://github.com/ma2za/python-substack"
16
16
  keywords = ["substack"]
17
17
 
18
18
  [tool.poetry.dependencies]
19
- python = ">=3.9"
19
+ python = "<4.0,>=3.10"
20
20
 
21
- requests = "^2.31.0"
22
- python-dotenv = "^0.21.0"
21
+ requests = "^2.32.0"
22
+ python-dotenv = "^1.2.1"
23
23
  PyYAML = "^6.0"
24
24
 
25
-
26
25
  [tool.poetry.group.dev.dependencies]
27
26
 
27
+ [tool.poetry.group.mcp]
28
+
29
+ optional = true
30
+
31
+ [tool.poetry.group.mcp.dependencies]
32
+ fastmcp = "^3.1.1"
28
33
 
29
34
  [build-system]
30
35
  requires = ["poetry-core>=1.0.0"]
@@ -3,7 +3,7 @@
3
3
  __author__ = "Paolo Mazza"
4
4
  __email__ = "mazzapaolo2019@gmail.com"
5
5
  __license__ = "MIT License"
6
- __version__ = "1.0"
6
+ __version__ = "0.1.21"
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"
@@ -8,19 +8,40 @@ import json
8
8
  import re
9
9
  from typing import Dict, List
10
10
 
11
- __all__ = ["Post", "parse_inline"]
11
+ __all__ = ["Post", "parse_inline", "tokens_to_text_nodes"]
12
12
 
13
13
  from substack.exceptions import SectionNotExistsException
14
14
 
15
15
 
16
+ def tokens_to_text_nodes(tokens: List[Dict]) -> List[Dict]:
17
+ """Convert parse_inline() tokens to ProseMirror text nodes.
18
+
19
+ parse_inline() returns {"content": "text", "marks": [...]}.
20
+ ProseMirror expects {"type": "text", "text": "text", "marks": [...]}.
21
+ """
22
+ nodes = []
23
+ for token in tokens:
24
+ if not token or not token.get("content"):
25
+ continue
26
+ node = {"type": "text", "text": token["content"]}
27
+ marks = token.get("marks")
28
+ if marks:
29
+ node["marks"] = marks
30
+ nodes.append(node)
31
+ return nodes
32
+
33
+
16
34
  def parse_inline(text: str) -> List[Dict]:
17
35
  """
18
36
  Convert inline Markdown in a text string into a list of tokens
19
37
  for use in the post content.
20
38
 
21
39
  Supported formatting:
40
+ - `code`: Text wrapped in backticks.
22
41
  - **Bold**: Text wrapped in double asterisks.
23
42
  - *Italic*: Text wrapped in single asterisks.
43
+ - ***Bold+Italic***: Text wrapped in triple asterisks.
44
+ - ~~Strikethrough~~: Text wrapped in double tildes.
24
45
  - [Links]: Text wrapped in square brackets followed by URL in parentheses.
25
46
 
26
47
  Args:
@@ -37,33 +58,50 @@ def parse_inline(text: str) -> List[Dict]:
37
58
  return []
38
59
 
39
60
  tokens = []
40
- # Process text character by character to handle nested formatting
41
- # We'll use regex to find all markdown patterns, then process them in order
42
61
 
43
- # Find all markdown patterns: links, bold, italic
44
- # Pattern order: links first (to avoid conflicts), then bold, then italic
62
+ # Pattern order matters: code > links > bold+italic > bold > italic > strikethrough
63
+ code_pattern = r'`([^`]+)`'
45
64
  link_pattern = r'\[([^\]]+)\]\(([^)]+)\)'
65
+ bold_italic_pattern = r'\*\*\*([^*]+)\*\*\*'
46
66
  bold_pattern = r'\*\*([^*]+)\*\*'
47
67
  italic_pattern = r'(?<!\*)\*([^*]+)\*(?!\*)' # Not preceded or followed by *
68
+ strikethrough_pattern = r'~~([^~]+)~~'
48
69
 
49
70
  # Find all matches with their positions
50
71
  matches = []
72
+
73
+ # Inline code FIRST -- content inside backticks must not be parsed for other formatting
74
+ for match in re.finditer(code_pattern, text):
75
+ matches.append((match.start(), match.end(), "code", match.group(1), None))
76
+
77
+ # Links
51
78
  for match in re.finditer(link_pattern, text):
52
79
  # Skip if it's an image link (starts with ![)
53
80
  # But do NOT skip normal links at position 0.
54
81
  if match.start() == 0 or text[match.start()-1:match.start()+1] != "![":
55
- matches.append((match.start(), match.end(), "link", match.group(1), match.group(2)))
82
+ if not any(start <= match.start() < end for start, end, _, _, _ in matches):
83
+ matches.append((match.start(), match.end(), "link", match.group(1), match.group(2)))
84
+
85
+ # Bold+italic combo
86
+ for match in re.finditer(bold_italic_pattern, text):
87
+ if not any(start <= match.start() < end for start, end, _, _, _ in matches):
88
+ matches.append((match.start(), match.end(), "bold_italic", match.group(1), None))
56
89
 
90
+ # Bold
57
91
  for match in re.finditer(bold_pattern, text):
58
- # Check if this range is already covered by a link
59
92
  if not any(start <= match.start() < end for start, end, _, _, _ in matches):
60
93
  matches.append((match.start(), match.end(), "bold", match.group(1), None))
61
94
 
95
+ # Italic
62
96
  for match in re.finditer(italic_pattern, text):
63
- # Check if this range is already covered by a link or bold
64
97
  if not any(start <= match.start() < end for start, end, _, _, _ in matches):
65
98
  matches.append((match.start(), match.end(), "italic", match.group(1), None))
66
99
 
100
+ # Strikethrough
101
+ for match in re.finditer(strikethrough_pattern, text):
102
+ if not any(start <= match.start() < end for start, end, _, _, _ in matches):
103
+ matches.append((match.start(), match.end(), "strikethrough", match.group(1), None))
104
+
67
105
  # Sort matches by position
68
106
  matches.sort(key=lambda x: x[0])
69
107
 
@@ -75,11 +113,21 @@ def parse_inline(text: str) -> List[Dict]:
75
113
  tokens.append({"content": text[last_pos:start]})
76
114
 
77
115
  # Add the formatted content
78
- if match_type == "link":
116
+ if match_type == "code":
117
+ tokens.append({
118
+ "content": content,
119
+ "marks": [{"type": "code"}]
120
+ })
121
+ elif match_type == "link":
79
122
  tokens.append({
80
123
  "content": content,
81
124
  "marks": [{"type": "link", "attrs": {"href": url}}]
82
125
  })
126
+ elif match_type == "bold_italic":
127
+ tokens.append({
128
+ "content": content,
129
+ "marks": [{"type": "strong"}, {"type": "em"}]
130
+ })
83
131
  elif match_type == "bold":
84
132
  tokens.append({
85
133
  "content": content,
@@ -90,6 +138,11 @@ def parse_inline(text: str) -> List[Dict]:
90
138
  "content": content,
91
139
  "marks": [{"type": "em"}]
92
140
  })
141
+ elif match_type == "strikethrough":
142
+ tokens.append({
143
+ "content": content,
144
+ "marks": [{"type": "strikethrough"}]
145
+ })
93
146
 
94
147
  last_pos = end
95
148
 
@@ -503,7 +556,9 @@ class Post:
503
556
  - Blockquotes: Lines starting with '>' (consecutive lines grouped)
504
557
  - Paragraphs: Regular text blocks
505
558
  - Bullet lists: Lines starting with '*' or '-'
506
- - Inline formatting: **bold** and *italic* within paragraphs
559
+ - Ordered lists: Lines starting with '1.', '2.', etc.
560
+ - Horizontal rules: Lines with ---, ***, or ___
561
+ - Inline formatting: **bold**, *italic*, ***bold+italic***, `code`, ~~strikethrough~~
507
562
 
508
563
  Args:
509
564
  markdown_content: Markdown string to parse and add to the post.
@@ -593,6 +648,11 @@ class Post:
593
648
  if not text_content:
594
649
  continue
595
650
 
651
+ # Check for horizontal rule: ---, ***, ___
652
+ if re.match(r'^(\*{3,}|-{3,}|_{3,})\s*$', text_content):
653
+ self.horizontal_rule()
654
+ continue
655
+
596
656
  # Process headings (lines starting with '#' characters)
597
657
  if text_content.startswith("#"):
598
658
  level = len(text_content) - len(text_content.lstrip("#"))
@@ -648,14 +708,15 @@ class Post:
648
708
 
649
709
  self.add({"type": "captionedImage", "src": image_url})
650
710
 
651
- # Process paragraphs, bullet lists, or blockquotes
711
+ # Process paragraphs, bullet lists, ordered lists, or blockquotes
652
712
  else:
653
713
  if "\n" in text_content:
654
- # Process each line, grouping consecutive bullets
655
- # into a single bullet_list node and consecutive
656
- # blockquote lines into a single blockquote node.
714
+ # Process each line, grouping consecutive bullets/ordered items
715
+ # into list nodes and consecutive blockquote lines into a
716
+ # single blockquote node.
657
717
  pending_bullets: List[List[Dict]] = []
658
718
  pending_quotes: List[str] = []
719
+ pending_ordered: List[List[Dict]] = []
659
720
 
660
721
  def flush_bullets():
661
722
  if not pending_bullets:
@@ -677,10 +738,7 @@ class Post:
677
738
  paragraphs: List[Dict] = []
678
739
  for quote_line in pending_quotes:
679
740
  tokens = parse_inline(quote_line)
680
- text_nodes = [
681
- {"type": "text", "text": t["content"]}
682
- for t in tokens if t
683
- ]
741
+ text_nodes = tokens_to_text_nodes(tokens)
684
742
  if text_nodes:
685
743
  paragraphs.append({"type": "paragraph", "content": text_nodes})
686
744
  node: Dict = {"type": "blockquote"}
@@ -689,20 +747,48 @@ class Post:
689
747
  self.draft_body["content"].append(node)
690
748
  pending_quotes.clear()
691
749
 
750
+ def flush_ordered():
751
+ if not pending_ordered:
752
+ return
753
+ list_items = []
754
+ for item_nodes in pending_ordered:
755
+ list_items.append({
756
+ "type": "list_item",
757
+ "content": [{"type": "paragraph", "content": item_nodes}],
758
+ })
759
+ self.draft_body["content"].append(
760
+ {"type": "ordered_list", "content": list_items}
761
+ )
762
+ pending_ordered.clear()
763
+
692
764
  for line in text_content.split("\n"):
693
765
  line = line.strip()
694
766
  if not line:
695
767
  flush_bullets()
768
+ flush_ordered()
696
769
  flush_quotes()
697
770
  continue
698
771
 
699
772
  # Check for blockquote marker
700
773
  if line.startswith("> ") or line == ">":
701
774
  flush_bullets()
775
+ flush_ordered()
702
776
  quote_text = line[2:] if line.startswith("> ") else ""
703
777
  pending_quotes.append(quote_text)
704
778
  continue
705
779
 
780
+ # Check for ordered list marker
781
+ ordered_match = re.match(r'^(\d+)\.\s+(.*)', line)
782
+ if ordered_match:
783
+ flush_bullets()
784
+ flush_quotes()
785
+ item_text = ordered_match.group(2).strip()
786
+ tokens = parse_inline(item_text)
787
+ text_nodes = tokens_to_text_nodes(tokens)
788
+ if text_nodes:
789
+ pending_ordered.append(text_nodes)
790
+ continue
791
+
706
792
  # Check for bullet marker
707
793
  bullet_text = None
708
794
  if line.startswith("* "):
@@ -713,31 +799,47 @@ class Post:
713
799
  bullet_text = line[1:].strip()
714
800
 
715
801
  if bullet_text is not None:
802
+ flush_ordered()
716
803
  flush_quotes()
717
804
  tokens = parse_inline(bullet_text)
718
- if tokens:
719
- pending_bullets.append(tokens)
805
+ text_nodes = tokens_to_text_nodes(tokens)
806
+ if text_nodes:
807
+ pending_bullets.append(text_nodes)
720
808
  else:
721
809
  flush_bullets()
810
+ flush_ordered()
722
811
  flush_quotes()
723
812
  tokens = parse_inline(line)
724
813
  self.add({"type": "paragraph", "content": tokens})
725
814
 
726
815
  flush_bullets()
816
+ flush_ordered()
727
817
  flush_quotes()
728
818
  else:
729
- # Single line — could be a blockquote or paragraph
819
+ # Single line — blockquote, ordered list, or paragraph
730
820
  if text_content.startswith("> ") or text_content == ">":
731
821
  quote_text = text_content[2:] if text_content.startswith("> ") else ""
732
822
  tokens = parse_inline(quote_text)
733
- text_nodes = [
734
- {"type": "text", "text": t["content"]}
735
- for t in tokens if t
736
- ]
823
+ text_nodes = tokens_to_text_nodes(tokens)
737
824
  para = {"type": "paragraph", "content": text_nodes} if text_nodes else {"type": "paragraph"}
738
825
  self.draft_body["content"] = self.draft_body.get("content", []) + [
739
826
  {"type": "blockquote", "content": [para]}
740
827
  ]
828
+
829
+ elif re.match(r'^(\d+)\.\s+(.*)', text_content):
830
+ ordered_match = re.match(r'^(\d+)\.\s+(.*)', text_content)
831
+ item_text = ordered_match.group(2).strip()
832
+ tokens = parse_inline(item_text)
833
+ text_nodes = tokens_to_text_nodes(tokens)
834
+ if text_nodes:
835
+ list_item = {
836
+ "type": "list_item",
837
+ "content": [{"type": "paragraph", "content": text_nodes}],
838
+ }
839
+ self.draft_body["content"].append(
840
+ {"type": "ordered_list", "content": [list_item]}
841
+ )
842
+
741
843
  else:
742
844
  tokens = parse_inline(text_content)
743
845
  self.add({"type": "paragraph", "content": tokens})