python-substack 0.1.17__tar.gz → 0.1.19__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,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-substack
3
- Version: 0.1.17
3
+ Version: 0.1.19
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,<4.0
10
+ Requires-Python: >=3.9
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.9
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.1.17"
3
+ version = "0.1.19"
4
4
  description = "A Python wrapper around the Substack API."
5
5
  authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
6
6
  license = "MIT"
@@ -16,7 +16,7 @@ homepage = "https://github.com/ma2za/python-substack"
16
16
  keywords = ["substack"]
17
17
 
18
18
  [tool.poetry.dependencies]
19
- python = "^3.9"
19
+ python = ">=3.9"
20
20
 
21
21
  requests = "^2.31.0"
22
22
  python-dotenv = "^0.21.0"
@@ -514,6 +514,67 @@ class Api:
514
514
  data={"image": image},
515
515
  )
516
516
  return Api._handle_response(response=response)
517
+
518
+ def add_tags_to_post(self, post_id: int, tag_names: list) -> dict:
519
+ """
520
+ Add multiple tags to a post.
521
+
522
+ Args:
523
+ post_id: The ID of the post to tag.
524
+ tag_names: A list of tag names to add.
525
+
526
+ Returns:
527
+ A dictionary with the results of applying all tags.
528
+ """
529
+ results = []
530
+ for tag_name in tag_names:
531
+ result = self.add_tag_to_post(post_id, tag_name)
532
+ results.append(result)
533
+ return {"tags_added": results}
534
+
535
+ def get_publication_post_tags(self) -> list:
536
+ """
537
+ Retrieve all post tags for the current publication.
538
+
539
+ Returns:
540
+ List of tag dicts as returned by Substack API.
541
+ """
542
+ response = self._session.get(f"{self.publication_url}/publication/post-tag")
543
+ return Api._handle_response(response=response)
544
+
545
+ def add_tag_to_post(self, post_id: int, tag_name: str) -> dict:
546
+ """
547
+ Add a tag to a post by first checking published tags and creating only if needed.
548
+
549
+ Args:
550
+ post_id: The ID of the post to tag.
551
+ tag_name: The name of the tag to add.
552
+
553
+ Returns:
554
+ The response from applying the tag to the post.
555
+ """
556
+ # Fetch existing publication tags first (avoid re-creating an already existing tag)
557
+ existing_tags = self.get_publication_post_tags() or []
558
+ existing_tag = next(
559
+ (tag for tag in existing_tags if tag.get("name") == tag_name),
560
+ None,
561
+ )
562
+
563
+ if existing_tag is not None:
564
+ tag_id = existing_tag["id"]
565
+ else:
566
+ create_tag_response = self._session.post(
567
+ f"{self.publication_url}/publication/post-tag",
568
+ json={"name": tag_name},
569
+ )
570
+ tag_data = Api._handle_response(create_tag_response)
571
+ tag_id = tag_data["id"]
572
+
573
+ apply_tag_response = self._session.post(
574
+ f"{self.publication_url}/post/{post_id}/tag/{tag_id}",
575
+ )
576
+ return Api._handle_response(apply_tag_response)
577
+
517
578
 
518
579
  def get_categories(self):
519
580
  """
@@ -39,40 +39,41 @@ def parse_inline(text: str) -> List[Dict]:
39
39
  tokens = []
40
40
  # Process text character by character to handle nested formatting
41
41
  # We'll use regex to find all markdown patterns, then process them in order
42
-
42
+
43
43
  # Find all markdown patterns: links, bold, italic
44
44
  # Pattern order: links first (to avoid conflicts), then bold, then italic
45
45
  link_pattern = r'\[([^\]]+)\]\(([^)]+)\)'
46
46
  bold_pattern = r'\*\*([^*]+)\*\*'
47
47
  italic_pattern = r'(?<!\*)\*([^*]+)\*(?!\*)' # Not preceded or followed by *
48
-
48
+
49
49
  # Find all matches with their positions
50
50
  matches = []
51
51
  for match in re.finditer(link_pattern, text):
52
52
  # Skip if it's an image link (starts with ![)
53
- if match.start() > 0 and text[match.start()-1:match.start()+1] != "![":
53
+ # But do NOT skip normal links at position 0.
54
+ if match.start() == 0 or text[match.start()-1:match.start()+1] != "![":
54
55
  matches.append((match.start(), match.end(), "link", match.group(1), match.group(2)))
55
-
56
+
56
57
  for match in re.finditer(bold_pattern, text):
57
58
  # Check if this range is already covered by a link
58
59
  if not any(start <= match.start() < end for start, end, _, _, _ in matches):
59
60
  matches.append((match.start(), match.end(), "bold", match.group(1), None))
60
-
61
+
61
62
  for match in re.finditer(italic_pattern, text):
62
63
  # Check if this range is already covered by a link or bold
63
64
  if not any(start <= match.start() < end for start, end, _, _, _ in matches):
64
65
  matches.append((match.start(), match.end(), "italic", match.group(1), None))
65
-
66
+
66
67
  # Sort matches by position
67
68
  matches.sort(key=lambda x: x[0])
68
-
69
+
69
70
  # Build tokens
70
71
  last_pos = 0
71
72
  for start, end, match_type, content, url in matches:
72
73
  # Add text before this match
73
74
  if start > last_pos:
74
75
  tokens.append({"content": text[last_pos:start]})
75
-
76
+
76
77
  # Add the formatted content
77
78
  if match_type == "link":
78
79
  tokens.append({
@@ -89,16 +90,16 @@ def parse_inline(text: str) -> List[Dict]:
89
90
  "content": content,
90
91
  "marks": [{"type": "em"}]
91
92
  })
92
-
93
+
93
94
  last_pos = end
94
-
95
+
95
96
  # Add remaining text
96
97
  if last_pos < len(text):
97
98
  tokens.append({"content": text[last_pos:]})
98
-
99
+
99
100
  # Filter out empty tokens
100
101
  tokens = [t for t in tokens if t.get("content")]
101
-
102
+
102
103
  return tokens
103
104
 
104
105
 
@@ -226,6 +227,42 @@ class Post:
226
227
  item["level"] = level
227
228
  return self.add(item)
228
229
 
230
+ def blockquote(self, content=None):
231
+ """
232
+ Add a blockquote to the post.
233
+
234
+ The blockquote wraps one or more paragraph nodes.
235
+
236
+ Args:
237
+ content: Text string or list of inline token dicts. When a plain
238
+ string is provided it is wrapped in a single paragraph node.
239
+
240
+ Returns:
241
+ Self for method chaining.
242
+ """
243
+ paragraphs: List[Dict] = []
244
+ if content is not None:
245
+ if isinstance(content, str):
246
+ tokens = parse_inline(content)
247
+ text_nodes = [
248
+ {"type": "text", "text": t["content"]} for t in tokens if t
249
+ ]
250
+ if text_nodes:
251
+ paragraphs.append({"type": "paragraph", "content": text_nodes})
252
+ elif isinstance(content, list):
253
+ for item in content:
254
+ if isinstance(item, dict) and item.get("type") == "paragraph":
255
+ paragraphs.append(item)
256
+ elif isinstance(item, dict):
257
+ text_nodes = [{"type": "text", "text": item.get("content", "")}]
258
+ paragraphs.append({"type": "paragraph", "content": text_nodes})
259
+
260
+ node: Dict = {"type": "blockquote"}
261
+ if paragraphs:
262
+ node["content"] = paragraphs
263
+ self.draft_body["content"] = self.draft_body.get("content", []) + [node]
264
+ return self
265
+
229
266
  def horizontal_rule(self):
230
267
  """
231
268
 
@@ -351,7 +388,7 @@ class Post:
351
388
  for mark in marks:
352
389
  new_mark = {"type": mark.get("type")}
353
390
  if mark.get("type") == "link":
354
- href = mark.get("href")
391
+ href = mark.get("href") or mark.get("attrs", {}).get("href")
355
392
  new_mark.update({"attrs": {"href": href}})
356
393
  content_marks.append(new_mark)
357
394
  content["marks"] = content_marks
@@ -463,6 +500,7 @@ class Post:
463
500
  - Linked images: [![Alt](image_url)](link_url) - images that are also links
464
501
  - Links: [text](url) - inline links in paragraphs
465
502
  - Code blocks: Fenced code blocks with ```language or ```
503
+ - Blockquotes: Lines starting with '>' (consecutive lines grouped)
466
504
  - Paragraphs: Regular text blocks
467
505
  - Bullet lists: Lines starting with '*' or '-'
468
506
  - Inline formatting: **bold** and *italic* within paragraphs
@@ -572,7 +610,7 @@ class Post:
572
610
  alt_text = linked_image_match.group(1)
573
611
  image_url = linked_image_match.group(2)
574
612
  link_url = linked_image_match.group(3)
575
-
613
+
576
614
  # Adjust image URL if it starts with a slash
577
615
  image_url = image_url[1:] if image_url.startswith("/") else image_url
578
616
 
@@ -610,28 +648,98 @@ class Post:
610
648
 
611
649
  self.add({"type": "captionedImage", "src": image_url})
612
650
 
613
- # Process paragraphs or bullet lists
651
+ # Process paragraphs, bullet lists, or blockquotes
614
652
  else:
615
653
  if "\n" in text_content:
616
- # Process each line separately (for bullet lists)
654
+ # Process each line, grouping consecutive bullets
655
+ # into a single bullet_list node and consecutive
656
+ # blockquote lines into a single blockquote node.
657
+ pending_bullets: List[List[Dict]] = []
658
+ pending_quotes: List[str] = []
659
+
660
+ def flush_bullets():
661
+ if not pending_bullets:
662
+ return
663
+ list_items = []
664
+ for bullet_nodes in pending_bullets:
665
+ list_items.append({
666
+ "type": "list_item",
667
+ "content": [{"type": "paragraph", "content": bullet_nodes}],
668
+ })
669
+ self.draft_body["content"].append(
670
+ {"type": "bullet_list", "content": list_items}
671
+ )
672
+ pending_bullets.clear()
673
+
674
+ def flush_quotes():
675
+ if not pending_quotes:
676
+ return
677
+ paragraphs: List[Dict] = []
678
+ for quote_line in pending_quotes:
679
+ tokens = parse_inline(quote_line)
680
+ text_nodes = [
681
+ {"type": "text", "text": t["content"]}
682
+ for t in tokens if t
683
+ ]
684
+ if text_nodes:
685
+ paragraphs.append({"type": "paragraph", "content": text_nodes})
686
+ node: Dict = {"type": "blockquote"}
687
+ if paragraphs:
688
+ node["content"] = paragraphs
689
+ self.draft_body["content"].append(node)
690
+ pending_quotes.clear()
691
+
617
692
  for line in text_content.split("\n"):
618
693
  line = line.strip()
619
694
  if not line:
695
+ flush_bullets()
696
+ flush_quotes()
697
+ continue
698
+
699
+ # Check for blockquote marker
700
+ if line.startswith("> ") or line == ">":
701
+ flush_bullets()
702
+ quote_text = line[2:] if line.startswith("> ") else ""
703
+ pending_quotes.append(quote_text)
620
704
  continue
621
- # Remove bullet marker if present
705
+
706
+ # Check for bullet marker
707
+ bullet_text = None
622
708
  if line.startswith("* "):
623
- line = line[2:].strip()
709
+ bullet_text = line[2:].strip()
624
710
  elif line.startswith("- "):
625
- line = line[2:].strip()
711
+ bullet_text = line[2:].strip()
626
712
  elif line.startswith("*") and not line.startswith("**"):
627
- line = line[1:].strip()
628
-
629
- if line:
713
+ bullet_text = line[1:].strip()
714
+
715
+ if bullet_text is not None:
716
+ flush_quotes()
717
+ tokens = parse_inline(bullet_text)
718
+ if tokens:
719
+ pending_bullets.append(tokens)
720
+ else:
721
+ flush_bullets()
722
+ flush_quotes()
630
723
  tokens = parse_inline(line)
631
724
  self.add({"type": "paragraph", "content": tokens})
725
+
726
+ flush_bullets()
727
+ flush_quotes()
632
728
  else:
633
- # Single paragraph
634
- tokens = parse_inline(text_content)
635
- self.add({"type": "paragraph", "content": tokens})
729
+ # Single line — could be a blockquote or paragraph
730
+ if text_content.startswith("> ") or text_content == ">":
731
+ quote_text = text_content[2:] if text_content.startswith("> ") else ""
732
+ tokens = parse_inline(quote_text)
733
+ text_nodes = [
734
+ {"type": "text", "text": t["content"]}
735
+ for t in tokens if t
736
+ ]
737
+ para = {"type": "paragraph", "content": text_nodes} if text_nodes else {"type": "paragraph"}
738
+ self.draft_body["content"] = self.draft_body.get("content", []) + [
739
+ {"type": "blockquote", "content": [para]}
740
+ ]
741
+ else:
742
+ tokens = parse_inline(text_content)
743
+ self.add({"type": "paragraph", "content": tokens})
636
744
 
637
745
  return self