python-substack 0.1.18__tar.gz → 0.1.20__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.18
3
+ Version: 0.1.20
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
@@ -237,7 +237,14 @@ for _, item in body.items():
237
237
  post.add(item)
238
238
 
239
239
  draft = api.post_draft(post.get_draft())
240
- api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
240
+ put_draft_kwargs = {
241
+ "draft_section_id": post.draft_section_id,
242
+ "search_engine_title": post_data.get("search_engine_title"),
243
+ "search_engine_description": post_data.get("search_engine_description"),
244
+ "slug": post_data.get("slug"),
245
+ }
246
+ put_draft_kwargs = {k: v for k, v in put_draft_kwargs.items() if v is not None}
247
+ api.put_draft(draft.get("id"), **put_draft_kwargs)
241
248
 
242
249
  # Publish the draft
243
250
  api.prepublish_draft(draft.get("id"))
@@ -212,7 +212,14 @@ for _, item in body.items():
212
212
  post.add(item)
213
213
 
214
214
  draft = api.post_draft(post.get_draft())
215
- api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
215
+ put_draft_kwargs = {
216
+ "draft_section_id": post.draft_section_id,
217
+ "search_engine_title": post_data.get("search_engine_title"),
218
+ "search_engine_description": post_data.get("search_engine_description"),
219
+ "slug": post_data.get("slug"),
220
+ }
221
+ put_draft_kwargs = {k: v for k, v in put_draft_kwargs.items() if v is not None}
222
+ api.put_draft(draft.get("id"), **put_draft_kwargs)
216
223
 
217
224
  # Publish the draft
218
225
  api.prepublish_draft(draft.get("id"))
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.1.18"
3
+ version = "0.1.20"
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
  """
@@ -227,6 +227,42 @@ class Post:
227
227
  item["level"] = level
228
228
  return self.add(item)
229
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
+
230
266
  def horizontal_rule(self):
231
267
  """
232
268
 
@@ -464,6 +500,7 @@ class Post:
464
500
  - Linked images: [![Alt](image_url)](link_url) - images that are also links
465
501
  - Links: [text](url) - inline links in paragraphs
466
502
  - Code blocks: Fenced code blocks with ```language or ```
503
+ - Blockquotes: Lines starting with '>' (consecutive lines grouped)
467
504
  - Paragraphs: Regular text blocks
468
505
  - Bullet lists: Lines starting with '*' or '-'
469
506
  - Inline formatting: **bold** and *italic* within paragraphs
@@ -611,12 +648,14 @@ class Post:
611
648
 
612
649
  self.add({"type": "captionedImage", "src": image_url})
613
650
 
614
- # Process paragraphs or bullet lists
651
+ # Process paragraphs, bullet lists, or blockquotes
615
652
  else:
616
653
  if "\n" in text_content:
617
654
  # Process each line, grouping consecutive bullets
618
- # into a single bullet_list node
655
+ # into a single bullet_list node and consecutive
656
+ # blockquote lines into a single blockquote node.
619
657
  pending_bullets: List[List[Dict]] = []
658
+ pending_quotes: List[str] = []
620
659
 
621
660
  def flush_bullets():
622
661
  if not pending_bullets:
@@ -632,10 +671,36 @@ class Post:
632
671
  )
633
672
  pending_bullets.clear()
634
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
+
635
692
  for line in text_content.split("\n"):
636
693
  line = line.strip()
637
694
  if not line:
638
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)
639
704
  continue
640
705
 
641
706
  # Check for bullet marker
@@ -648,18 +713,33 @@ class Post:
648
713
  bullet_text = line[1:].strip()
649
714
 
650
715
  if bullet_text is not None:
716
+ flush_quotes()
651
717
  tokens = parse_inline(bullet_text)
652
718
  if tokens:
653
719
  pending_bullets.append(tokens)
654
720
  else:
655
721
  flush_bullets()
722
+ flush_quotes()
656
723
  tokens = parse_inline(line)
657
724
  self.add({"type": "paragraph", "content": tokens})
658
725
 
659
726
  flush_bullets()
727
+ flush_quotes()
660
728
  else:
661
- # Single paragraph
662
- tokens = parse_inline(text_content)
663
- 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})
664
744
 
665
745
  return self