python-substack 0.1.23__py3-none-any.whl → 0.1.24__py3-none-any.whl
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.23.dist-info → python_substack-0.1.24.dist-info}/METADATA +4 -1
- python_substack-0.1.24.dist-info/RECORD +10 -0
- substack/__init__.py +1 -1
- substack/mdrender.py +210 -0
- substack/nodes.py +148 -0
- substack/post.py +8 -421
- python_substack-0.1.23.dist-info/RECORD +0 -8
- {python_substack-0.1.23.dist-info → python_substack-0.1.24.dist-info}/WHEEL +0 -0
- {python_substack-0.1.23.dist-info → python_substack-0.1.24.dist-info}/licenses/LICENSE +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.24
|
|
4
4
|
Summary: A Python wrapper around the Substack API.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -16,6 +16,8 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
18
|
Requires-Dist: PyYAML (>=6.0,<7.0)
|
|
19
|
+
Requires-Dist: markdown-it-py (>=3.0,<4.0)
|
|
20
|
+
Requires-Dist: mdit-py-plugins (>=0.4,<0.5)
|
|
19
21
|
Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
|
|
20
22
|
Requires-Dist: requests (>=2.32.0,<3.0.0)
|
|
21
23
|
Project-URL: Homepage, https://github.com/ma2za/python-substack
|
|
@@ -195,6 +197,7 @@ post.from_markdown(footnote_markdown, api=api)
|
|
|
195
197
|
post.paragraph(content=[{"content": "Some claim."}]).footnote_anchor(1)
|
|
196
198
|
post.footnote(1, "The note text, with **formatting** allowed.")
|
|
197
199
|
|
|
200
|
+
|
|
198
201
|
draft = api.post_draft(post.get_draft())
|
|
199
202
|
|
|
200
203
|
# set section (can only be done after first posting the draft)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
substack/__init__.py,sha256=esdUffBuSMESXcG4GDcHU81TIPXLyDosmBN3OCtAFY4,390
|
|
2
|
+
substack/api.py,sha256=VPzQ_bpqGcE4ZXWD2lUppnvWsuLRjvtBN7bHmW4Bgww,20422
|
|
3
|
+
substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
|
|
4
|
+
substack/mdrender.py,sha256=cB0fLFzOF7CmWHk-9eRvQormP8StqIgy9hYE6CSKPH0,7770
|
|
5
|
+
substack/nodes.py,sha256=eFVxoVwi684g_DLbz8BRHfU7nyHsJb-m8w17UHocXgY,4106
|
|
6
|
+
substack/post.py,sha256=nXeZMAZ6-lx1Qf4yuAlrEQ0lcWFYF2afai1_TxVEl0Q,19704
|
|
7
|
+
python_substack-0.1.24.dist-info/METADATA,sha256=0SKSa0SYQ9Ju5jy7br0JOwAKKIttnCmGd70qMrcQmDk,9868
|
|
8
|
+
python_substack-0.1.24.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
9
|
+
python_substack-0.1.24.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
10
|
+
python_substack-0.1.24.dist-info/RECORD,,
|
substack/__init__.py
CHANGED
|
@@ -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.24"
|
|
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"
|
substack/mdrender.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Markdown -> Substack ProseMirror via markdown-it-py.
|
|
2
|
+
|
|
3
|
+
Implements Post.from_markdown() using a real CommonMark parser (markdown-it-py)
|
|
4
|
+
plus the standard footnote plugin, with a small renderer that walks the syntax
|
|
5
|
+
tree into Substack's node schema.
|
|
6
|
+
|
|
7
|
+
Node construction goes through ``substack.nodes`` so the (undocumented) schema
|
|
8
|
+
lives in exactly one place.
|
|
9
|
+
|
|
10
|
+
Footnotes: Substack numbers footnote anchors by their position in the document
|
|
11
|
+
and pairs them one-to-one, in order, with the footnote blocks at the end (it
|
|
12
|
+
ignores any explicit number and does not support one block serving several
|
|
13
|
+
anchors). So each reference is emitted as its own sequentially-numbered anchor,
|
|
14
|
+
and a matching footnote block is appended for each -- a definition referenced
|
|
15
|
+
more than once is duplicated, which mirrors how Substack's own editor behaves.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import copy
|
|
21
|
+
from typing import Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from markdown_it import MarkdownIt
|
|
24
|
+
from markdown_it.tree import SyntaxTreeNode
|
|
25
|
+
from mdit_py_plugins.footnote import footnote_plugin
|
|
26
|
+
|
|
27
|
+
from substack import nodes
|
|
28
|
+
from substack.nodes import MarkType, NodeType
|
|
29
|
+
|
|
30
|
+
_MARK_FOR = {
|
|
31
|
+
"strong": {"type": MarkType.STRONG},
|
|
32
|
+
"em": {"type": MarkType.EM},
|
|
33
|
+
"s": {"type": MarkType.STRIKETHROUGH},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _make_parser() -> MarkdownIt:
|
|
38
|
+
return MarkdownIt("commonmark").use(footnote_plugin).enable("strikethrough")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _coalesce(out_nodes: List[Dict]) -> List[Dict]:
|
|
42
|
+
"""Merge adjacent text nodes that carry identical marks (e.g. softbreaks)."""
|
|
43
|
+
merged: List[Dict] = []
|
|
44
|
+
for node in out_nodes:
|
|
45
|
+
if (
|
|
46
|
+
merged
|
|
47
|
+
and node.get("type") == NodeType.TEXT
|
|
48
|
+
and merged[-1].get("type") == NodeType.TEXT
|
|
49
|
+
and node.get("marks") == merged[-1].get("marks")
|
|
50
|
+
):
|
|
51
|
+
merged[-1]["text"] += node["text"]
|
|
52
|
+
else:
|
|
53
|
+
merged.append(node)
|
|
54
|
+
return merged
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[Dict]:
|
|
58
|
+
"""Render an inline subtree into a flat list of text / anchor nodes."""
|
|
59
|
+
out: List[Dict] = []
|
|
60
|
+
for child in node.children:
|
|
61
|
+
t = child.type
|
|
62
|
+
if t == "text":
|
|
63
|
+
if child.content:
|
|
64
|
+
out.append(nodes.text(child.content, marks))
|
|
65
|
+
elif t == "code_inline":
|
|
66
|
+
out.append(nodes.text(child.content, marks + [nodes.code_mark()]))
|
|
67
|
+
elif t in _MARK_FOR:
|
|
68
|
+
out.extend(_render_inline(child, marks + [_MARK_FOR[t]], ctx))
|
|
69
|
+
elif t == "link":
|
|
70
|
+
href = child.attrs.get("href", "")
|
|
71
|
+
out.extend(_render_inline(child, marks + [nodes.link_mark(href)], ctx))
|
|
72
|
+
elif t in ("softbreak", "hardbreak"):
|
|
73
|
+
out.append(nodes.text(" ", marks))
|
|
74
|
+
elif t == "footnote_ref":
|
|
75
|
+
# Number anchors by document position and record which definition each
|
|
76
|
+
# one points to, so matching blocks can be emitted 1:1 afterwards.
|
|
77
|
+
ctx["order"].append(child.meta["id"])
|
|
78
|
+
out.append(nodes.footnote_anchor(len(ctx["order"])))
|
|
79
|
+
elif t == "image":
|
|
80
|
+
# Inline images are rare in this schema; fall back to alt text.
|
|
81
|
+
alt = child.attrs.get("alt") or "".join(
|
|
82
|
+
c.content for c in child.children if c.type == "text"
|
|
83
|
+
)
|
|
84
|
+
if alt:
|
|
85
|
+
out.append(nodes.text(alt, marks))
|
|
86
|
+
return _coalesce(out)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _only_image(inline: SyntaxTreeNode) -> Optional[SyntaxTreeNode]:
|
|
90
|
+
"""If an inline node is just an image (optionally wrapped in a link), return it."""
|
|
91
|
+
kids = [c for c in inline.children if c.type != "softbreak"]
|
|
92
|
+
if len(kids) == 1 and kids[0].type == "image":
|
|
93
|
+
return kids[0]
|
|
94
|
+
if len(kids) == 1 and kids[0].type == "link":
|
|
95
|
+
inner = [c for c in kids[0].children if c.type != "softbreak"]
|
|
96
|
+
if len(inner) == 1 and inner[0].type == "image":
|
|
97
|
+
img = inner[0]
|
|
98
|
+
img._link_href = kids[0].attrs.get("href") # type: ignore[attr-defined]
|
|
99
|
+
return img
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _captioned_image(img: SyntaxTreeNode, api) -> Dict:
|
|
104
|
+
src = img.attrs.get("src", "")
|
|
105
|
+
if src.startswith("/"):
|
|
106
|
+
src = src[1:]
|
|
107
|
+
if api is not None and not src.startswith("http"):
|
|
108
|
+
try:
|
|
109
|
+
src = api.get_image(src).get("url")
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
# markdown-it stores the image alt text as the node's content, not in attrs.
|
|
113
|
+
alt = img.content or img.attrs.get("alt") or None
|
|
114
|
+
# Standard markdown image title `` maps to Substack's caption node.
|
|
115
|
+
title = img.attrs.get("title") or None
|
|
116
|
+
caption = [nodes.text(title)] if title else None
|
|
117
|
+
return nodes.captioned_image(
|
|
118
|
+
src,
|
|
119
|
+
alt=alt,
|
|
120
|
+
href=getattr(img, "_link_href", None),
|
|
121
|
+
caption=caption,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _render_block(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
|
|
126
|
+
"""Render a block-level node into zero or more Substack nodes."""
|
|
127
|
+
t = node.type
|
|
128
|
+
|
|
129
|
+
if t == "paragraph":
|
|
130
|
+
inline = node.children[0]
|
|
131
|
+
img = _only_image(inline)
|
|
132
|
+
if img is not None:
|
|
133
|
+
return [_captioned_image(img, api)]
|
|
134
|
+
return [nodes.paragraph(_render_inline(inline, [], ctx))]
|
|
135
|
+
|
|
136
|
+
if t == "heading":
|
|
137
|
+
level = int(node.tag[1])
|
|
138
|
+
return [nodes.heading(_render_inline(node.children[0], [], ctx), level=level)]
|
|
139
|
+
|
|
140
|
+
if t == "hr":
|
|
141
|
+
return [nodes.horizontal_rule()]
|
|
142
|
+
|
|
143
|
+
if t in ("fence", "code_block"):
|
|
144
|
+
return [
|
|
145
|
+
nodes.code_block(
|
|
146
|
+
node.content.rstrip("\n"), language=node.info.strip() or None
|
|
147
|
+
)
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
if t == "blockquote":
|
|
151
|
+
paras: List[Dict] = []
|
|
152
|
+
for child in node.children:
|
|
153
|
+
paras.extend(_render_block(child, api, ctx))
|
|
154
|
+
return [nodes.blockquote(paras)]
|
|
155
|
+
|
|
156
|
+
if t == "bullet_list":
|
|
157
|
+
return [nodes.bullet_list(_render_list_items(node, api, ctx))]
|
|
158
|
+
|
|
159
|
+
if t == "ordered_list":
|
|
160
|
+
return [nodes.ordered_list(_render_list_items(node, api, ctx))]
|
|
161
|
+
|
|
162
|
+
# footnote_block is handled separately in markdown_to_doc; ignore it here.
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _render_list_items(list_node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
|
|
167
|
+
items = []
|
|
168
|
+
for li in list_node.children:
|
|
169
|
+
content: List[Dict] = []
|
|
170
|
+
for child in li.children:
|
|
171
|
+
content.extend(_render_block(child, api, ctx))
|
|
172
|
+
items.append({"type": NodeType.LIST_ITEM, "content": content})
|
|
173
|
+
return items
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _footnote_definitions(tree: SyntaxTreeNode, api) -> Dict[int, List[Dict]]:
|
|
177
|
+
"""Map each footnote id to its rendered block content."""
|
|
178
|
+
definitions: Dict[int, List[Dict]] = {}
|
|
179
|
+
for node in tree.children:
|
|
180
|
+
if node.type != "footnote_block":
|
|
181
|
+
continue
|
|
182
|
+
for fn in node.children:
|
|
183
|
+
# A footnote's own content should not register anchors of its own.
|
|
184
|
+
local_ctx = {"order": []}
|
|
185
|
+
content: List[Dict] = []
|
|
186
|
+
for child in fn.children:
|
|
187
|
+
content.extend(_render_block(child, api, local_ctx))
|
|
188
|
+
definitions[fn.meta["id"]] = content
|
|
189
|
+
return definitions
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]:
|
|
193
|
+
"""Convert Markdown into a list of Substack ProseMirror block nodes."""
|
|
194
|
+
tree = SyntaxTreeNode(_make_parser().parse(markdown_content))
|
|
195
|
+
|
|
196
|
+
definitions = _footnote_definitions(tree, api)
|
|
197
|
+
|
|
198
|
+
ctx: Dict = {"order": []}
|
|
199
|
+
out: List[Dict] = []
|
|
200
|
+
for node in tree.children:
|
|
201
|
+
if node.type == "footnote_block":
|
|
202
|
+
continue
|
|
203
|
+
out.extend(_render_block(node, api, ctx))
|
|
204
|
+
|
|
205
|
+
# Emit one footnote block per reference, in anchor order, numbered to match.
|
|
206
|
+
for number, footnote_id in enumerate(ctx["order"], start=1):
|
|
207
|
+
content = copy.deepcopy(definitions.get(footnote_id, []))
|
|
208
|
+
out.append(nodes.footnote(number, content))
|
|
209
|
+
|
|
210
|
+
return out
|
substack/nodes.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""ProseMirror node builders for Substack documents.
|
|
2
|
+
|
|
3
|
+
Centralises the (undocumented) Substack ProseMirror schema in one place.
|
|
4
|
+
The node-type strings ("paragraph", "footnoteAnchor", "image2", ...) and
|
|
5
|
+
their shapes live here rather than as inline dict literals scattered across
|
|
6
|
+
post.py, giving:
|
|
7
|
+
|
|
8
|
+
* one source of truth for node shapes (so a schema change is a one-line fix),
|
|
9
|
+
* discoverable, typed constructors instead of bare dict literals,
|
|
10
|
+
* a natural seam for validation.
|
|
11
|
+
|
|
12
|
+
The builders intentionally return plain dicts so they stay 100% compatible with
|
|
13
|
+
the existing draft_body structure.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NodeType:
|
|
22
|
+
DOC = "doc"
|
|
23
|
+
PARAGRAPH = "paragraph"
|
|
24
|
+
HEADING = "heading"
|
|
25
|
+
TEXT = "text"
|
|
26
|
+
BLOCKQUOTE = "blockquote"
|
|
27
|
+
CODE_BLOCK = "codeBlock"
|
|
28
|
+
HORIZONTAL_RULE = "horizontal_rule"
|
|
29
|
+
BULLET_LIST = "bullet_list"
|
|
30
|
+
ORDERED_LIST = "ordered_list"
|
|
31
|
+
LIST_ITEM = "list_item"
|
|
32
|
+
FOOTNOTE = "footnote"
|
|
33
|
+
FOOTNOTE_ANCHOR = "footnoteAnchor"
|
|
34
|
+
CAPTIONED_IMAGE = "captionedImage"
|
|
35
|
+
CAPTION = "caption"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MarkType:
|
|
39
|
+
STRONG = "strong"
|
|
40
|
+
EM = "em"
|
|
41
|
+
CODE = "code"
|
|
42
|
+
STRIKETHROUGH = "strikethrough"
|
|
43
|
+
LINK = "link"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def code_mark() -> Dict:
|
|
47
|
+
return {"type": MarkType.CODE}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def text(value: str, marks: Optional[List[Dict]] = None) -> Dict:
|
|
51
|
+
node: Dict = {"type": NodeType.TEXT, "text": value}
|
|
52
|
+
if marks:
|
|
53
|
+
node["marks"] = marks
|
|
54
|
+
return node
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def link_mark(href: str) -> Dict:
|
|
58
|
+
return {"type": MarkType.LINK, "attrs": {"href": href}}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def paragraph(content: Optional[List[Dict]] = None) -> Dict:
|
|
62
|
+
return {"type": NodeType.PARAGRAPH, "content": content or []}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def heading(content: List[Dict], level: int = 1) -> Dict:
|
|
66
|
+
return {"type": NodeType.HEADING, "content": content, "attrs": {"level": level}}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def horizontal_rule() -> Dict:
|
|
70
|
+
return {"type": NodeType.HORIZONTAL_RULE}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def blockquote(paragraphs: List[Dict]) -> Dict:
|
|
74
|
+
node: Dict = {"type": NodeType.BLOCKQUOTE}
|
|
75
|
+
if paragraphs:
|
|
76
|
+
node["content"] = paragraphs
|
|
77
|
+
return node
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def list_item(content_nodes: List[Dict]) -> Dict:
|
|
81
|
+
return {
|
|
82
|
+
"type": NodeType.LIST_ITEM,
|
|
83
|
+
"content": [paragraph(content_nodes)],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def bullet_list(items: List[Dict]) -> Dict:
|
|
88
|
+
return {"type": NodeType.BULLET_LIST, "content": items}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def ordered_list(items: List[Dict]) -> Dict:
|
|
92
|
+
return {"type": NodeType.ORDERED_LIST, "content": items}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def code_block(code: str, language: Optional[str] = None) -> Dict:
|
|
96
|
+
node: Dict = {"type": NodeType.CODE_BLOCK, "content": [text(code)]}
|
|
97
|
+
if language:
|
|
98
|
+
node["attrs"] = {"language": language}
|
|
99
|
+
return node
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def captioned_image(
|
|
103
|
+
src: str,
|
|
104
|
+
alt: Optional[str] = None,
|
|
105
|
+
href: Optional[str] = None,
|
|
106
|
+
caption: Optional[List[Dict]] = None,
|
|
107
|
+
image_size: str = "normal",
|
|
108
|
+
) -> Dict:
|
|
109
|
+
content: List[Dict] = [
|
|
110
|
+
{
|
|
111
|
+
"type": "image2",
|
|
112
|
+
"attrs": {
|
|
113
|
+
"src": src,
|
|
114
|
+
"srcNoWatermark": None,
|
|
115
|
+
"fullscreen": False,
|
|
116
|
+
"imageSize": image_size,
|
|
117
|
+
"height": 819,
|
|
118
|
+
"width": 1456,
|
|
119
|
+
"resizeWidth": 728 if image_size == "normal" else None,
|
|
120
|
+
"bytes": None,
|
|
121
|
+
"alt": alt,
|
|
122
|
+
"title": None,
|
|
123
|
+
"type": None,
|
|
124
|
+
"href": href,
|
|
125
|
+
"belowTheFold": False,
|
|
126
|
+
"topImage": False,
|
|
127
|
+
"internalRedirect": None,
|
|
128
|
+
"isProcessing": False,
|
|
129
|
+
"align": None,
|
|
130
|
+
"offset": False,
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
if caption:
|
|
135
|
+
content.append({"type": NodeType.CAPTION, "content": caption})
|
|
136
|
+
return {"type": NodeType.CAPTIONED_IMAGE, "content": content}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def footnote_anchor(number: int) -> Dict:
|
|
140
|
+
return {"type": NodeType.FOOTNOTE_ANCHOR, "attrs": {"number": number}}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def footnote(number: int, paragraphs: List[Dict]) -> Dict:
|
|
144
|
+
return {
|
|
145
|
+
"type": NodeType.FOOTNOTE,
|
|
146
|
+
"attrs": {"number": number},
|
|
147
|
+
"content": paragraphs or [paragraph()],
|
|
148
|
+
}
|
substack/post.py
CHANGED
|
@@ -11,10 +11,7 @@ from typing import Dict, List
|
|
|
11
11
|
__all__ = ["Post", "parse_inline", "tokens_to_text_nodes"]
|
|
12
12
|
|
|
13
13
|
from substack.exceptions import SectionNotExistsException
|
|
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?(.*)$")
|
|
14
|
+
from substack import nodes
|
|
18
15
|
|
|
19
16
|
|
|
20
17
|
def tokens_to_text_nodes(tokens: List[Dict]) -> List[Dict]:
|
|
@@ -560,7 +557,7 @@ class Post:
|
|
|
560
557
|
|
|
561
558
|
"""
|
|
562
559
|
content = self.draft_body["content"][-1].get("content", [])
|
|
563
|
-
content += [
|
|
560
|
+
content += [nodes.footnote_anchor(number)]
|
|
564
561
|
self.draft_body["content"][-1]["content"] = content
|
|
565
562
|
return self
|
|
566
563
|
|
|
@@ -586,147 +583,19 @@ class Post:
|
|
|
586
583
|
for chunk in re.split(r"\n\s*\n", content):
|
|
587
584
|
chunk = chunk.strip()
|
|
588
585
|
if chunk:
|
|
589
|
-
paragraphs.append(
|
|
590
|
-
{"type": "paragraph", "content": tokens_to_text_nodes(parse_inline(chunk))}
|
|
591
|
-
)
|
|
586
|
+
paragraphs.append(nodes.paragraph(tokens_to_text_nodes(parse_inline(chunk))))
|
|
592
587
|
elif isinstance(content, list):
|
|
593
588
|
# Accept either parse_inline tokens ({"content": ...}) or text nodes.
|
|
594
589
|
if content and content[0].get("type") == "text":
|
|
595
590
|
text_nodes = content
|
|
596
591
|
else:
|
|
597
592
|
text_nodes = tokens_to_text_nodes(content)
|
|
598
|
-
paragraphs.append(
|
|
599
|
-
|
|
600
|
-
if not paragraphs:
|
|
601
|
-
paragraphs = [{"type": "paragraph", "content": []}]
|
|
593
|
+
paragraphs.append(nodes.paragraph(text_nodes))
|
|
602
594
|
|
|
603
|
-
node: Dict =
|
|
604
|
-
"type": "footnote",
|
|
605
|
-
"attrs": {"number": number},
|
|
606
|
-
"content": paragraphs,
|
|
607
|
-
}
|
|
595
|
+
node: Dict = nodes.footnote(number, paragraphs)
|
|
608
596
|
self.draft_body["content"] = self.draft_body.get("content", []) + [node]
|
|
609
597
|
return self
|
|
610
598
|
|
|
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
|
-
|
|
730
599
|
def from_markdown(self, markdown_content: str, api=None):
|
|
731
600
|
"""
|
|
732
601
|
Parse Markdown content and add it to the post.
|
|
@@ -760,290 +629,8 @@ class Post:
|
|
|
760
629
|
>>> post = Post("Title", "Subtitle", user_id)
|
|
761
630
|
>>> post.from_markdown("# Heading\\n\\nThis is **bold** text with [a link](https://example.com).")
|
|
762
631
|
"""
|
|
763
|
-
|
|
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
|
-
|
|
770
|
-
lines = markdown_content.split("\n")
|
|
771
|
-
blocks = []
|
|
772
|
-
current_block: List[str] = []
|
|
773
|
-
in_code_block = False
|
|
774
|
-
code_block_language = None
|
|
775
|
-
|
|
776
|
-
for line in lines:
|
|
777
|
-
# Check for fenced code block start/end
|
|
778
|
-
if line.strip().startswith("```"):
|
|
779
|
-
if in_code_block:
|
|
780
|
-
# End of code block
|
|
781
|
-
if current_block:
|
|
782
|
-
blocks.append({
|
|
783
|
-
"type": "code",
|
|
784
|
-
"language": code_block_language,
|
|
785
|
-
"content": "\n".join(current_block)
|
|
786
|
-
})
|
|
787
|
-
current_block = []
|
|
788
|
-
in_code_block = False
|
|
789
|
-
code_block_language = None
|
|
790
|
-
else:
|
|
791
|
-
# Start of code block
|
|
792
|
-
if current_block:
|
|
793
|
-
blocks.append({"type": "text", "content": "\n".join(current_block)})
|
|
794
|
-
current_block = []
|
|
795
|
-
# Extract language if specified
|
|
796
|
-
language = line.strip()[3:].strip()
|
|
797
|
-
code_block_language = language if language else None
|
|
798
|
-
in_code_block = True
|
|
799
|
-
continue
|
|
800
|
-
|
|
801
|
-
if in_code_block:
|
|
802
|
-
# Inside code block - collect lines as-is
|
|
803
|
-
current_block.append(line)
|
|
804
|
-
else:
|
|
805
|
-
# Regular content
|
|
806
|
-
if line.strip() == "":
|
|
807
|
-
# Empty line - end current block if it has content
|
|
808
|
-
if current_block:
|
|
809
|
-
blocks.append({"type": "text", "content": "\n".join(current_block)})
|
|
810
|
-
current_block = []
|
|
811
|
-
else:
|
|
812
|
-
current_block.append(line)
|
|
813
|
-
|
|
814
|
-
# Add any remaining content
|
|
815
|
-
if current_block:
|
|
816
|
-
if in_code_block:
|
|
817
|
-
blocks.append({
|
|
818
|
-
"type": "code",
|
|
819
|
-
"language": code_block_language,
|
|
820
|
-
"content": "\n".join(current_block)
|
|
821
|
-
})
|
|
822
|
-
else:
|
|
823
|
-
blocks.append({"type": "text", "content": "\n".join(current_block)})
|
|
824
|
-
|
|
825
|
-
# Process blocks
|
|
826
|
-
for block in blocks:
|
|
827
|
-
if block["type"] == "code":
|
|
828
|
-
# Add code block
|
|
829
|
-
code_content = block.get("content", "").strip()
|
|
830
|
-
if code_content:
|
|
831
|
-
# Substack uses "codeBlock" type
|
|
832
|
-
code_attrs = {}
|
|
833
|
-
if block.get("language"):
|
|
834
|
-
code_attrs["language"] = block["language"]
|
|
835
|
-
self.add({
|
|
836
|
-
"type": "codeBlock",
|
|
837
|
-
"content": code_content, # Pass as string, code_block method will handle it
|
|
838
|
-
"attrs": code_attrs
|
|
839
|
-
})
|
|
840
|
-
else:
|
|
841
|
-
# Process text block
|
|
842
|
-
text_content = block.get("content", "").strip()
|
|
843
|
-
if not text_content:
|
|
844
|
-
continue
|
|
845
|
-
|
|
846
|
-
# Check for horizontal rule: ---, ***, ___
|
|
847
|
-
if re.match(r'^(\*{3,}|-{3,}|_{3,})\s*$', text_content):
|
|
848
|
-
self.horizontal_rule()
|
|
849
|
-
continue
|
|
850
|
-
|
|
851
|
-
# Process headings (lines starting with '#' characters)
|
|
852
|
-
if text_content.startswith("#"):
|
|
853
|
-
level = len(text_content) - len(text_content.lstrip("#"))
|
|
854
|
-
heading_text = text_content.lstrip("#").strip()
|
|
855
|
-
if heading_text: # Only add if there's actual text
|
|
856
|
-
self.heading(content=heading_text, level=min(level, 6))
|
|
857
|
-
|
|
858
|
-
# Process images using Markdown image syntax: 
|
|
859
|
-
# Also handle linked images: [](link_url)
|
|
860
|
-
elif text_content.startswith("!") or (text_content.startswith("[") and "](link)
|
|
862
|
-
linked_image_match = re.match(r'\[!\[([^\]]*)\]\(([^)]+)\)\]\(([^)]+)\)', text_content)
|
|
863
|
-
if linked_image_match:
|
|
864
|
-
# Linked image - create image with href
|
|
865
|
-
alt_text = linked_image_match.group(1)
|
|
866
|
-
image_url = linked_image_match.group(2)
|
|
867
|
-
link_url = linked_image_match.group(3)
|
|
868
|
-
|
|
869
|
-
# Adjust image URL if it starts with a slash
|
|
870
|
-
image_url = image_url[1:] if image_url.startswith("/") else image_url
|
|
871
|
-
|
|
872
|
-
# If api is provided and image_url is a local file, upload it
|
|
873
|
-
if api is not None:
|
|
874
|
-
try:
|
|
875
|
-
image = api.get_image(image_url)
|
|
876
|
-
image_url = image.get("url")
|
|
877
|
-
except Exception:
|
|
878
|
-
# If upload fails, use original URL
|
|
879
|
-
pass
|
|
880
|
-
|
|
881
|
-
self.add({
|
|
882
|
-
"type": "captionedImage",
|
|
883
|
-
"src": image_url,
|
|
884
|
-
"alt": alt_text,
|
|
885
|
-
"href": link_url
|
|
886
|
-
})
|
|
887
|
-
else:
|
|
888
|
-
# Regular image: 
|
|
889
|
-
match = re.match(r"!\[.*?\]\((.*?)\)", text_content)
|
|
890
|
-
if match:
|
|
891
|
-
image_url = match.group(1)
|
|
892
|
-
# Adjust image URL if it starts with a slash
|
|
893
|
-
image_url = image_url[1:] if image_url.startswith("/") else image_url
|
|
894
|
-
|
|
895
|
-
# If api is provided and image_url is a local file, upload it
|
|
896
|
-
if api is not None:
|
|
897
|
-
try:
|
|
898
|
-
image = api.get_image(image_url)
|
|
899
|
-
image_url = image.get("url")
|
|
900
|
-
except Exception:
|
|
901
|
-
# If upload fails, use original URL
|
|
902
|
-
pass
|
|
903
|
-
|
|
904
|
-
self.add({"type": "captionedImage", "src": image_url})
|
|
905
|
-
|
|
906
|
-
# Process paragraphs, bullet lists, ordered lists, or blockquotes
|
|
907
|
-
else:
|
|
908
|
-
if "\n" in text_content:
|
|
909
|
-
# Process each line, grouping consecutive bullets/ordered items
|
|
910
|
-
# into list nodes and consecutive blockquote lines into a
|
|
911
|
-
# single blockquote node.
|
|
912
|
-
pending_bullets: List[List[Dict]] = []
|
|
913
|
-
pending_quotes: List[str] = []
|
|
914
|
-
pending_ordered: List[List[Dict]] = []
|
|
915
|
-
|
|
916
|
-
def flush_bullets():
|
|
917
|
-
if not pending_bullets:
|
|
918
|
-
return
|
|
919
|
-
list_items = []
|
|
920
|
-
for bullet_nodes in pending_bullets:
|
|
921
|
-
list_items.append({
|
|
922
|
-
"type": "list_item",
|
|
923
|
-
"content": [{"type": "paragraph", "content": bullet_nodes}],
|
|
924
|
-
})
|
|
925
|
-
self.draft_body["content"].append(
|
|
926
|
-
{"type": "bullet_list", "content": list_items}
|
|
927
|
-
)
|
|
928
|
-
pending_bullets.clear()
|
|
929
|
-
|
|
930
|
-
def flush_quotes():
|
|
931
|
-
if not pending_quotes:
|
|
932
|
-
return
|
|
933
|
-
paragraphs: List[Dict] = []
|
|
934
|
-
for quote_line in pending_quotes:
|
|
935
|
-
tokens = parse_inline(quote_line)
|
|
936
|
-
text_nodes = tokens_to_text_nodes(tokens)
|
|
937
|
-
if text_nodes:
|
|
938
|
-
paragraphs.append({"type": "paragraph", "content": text_nodes})
|
|
939
|
-
node: Dict = {"type": "blockquote"}
|
|
940
|
-
if paragraphs:
|
|
941
|
-
node["content"] = paragraphs
|
|
942
|
-
self.draft_body["content"].append(node)
|
|
943
|
-
pending_quotes.clear()
|
|
944
|
-
|
|
945
|
-
def flush_ordered():
|
|
946
|
-
if not pending_ordered:
|
|
947
|
-
return
|
|
948
|
-
list_items = []
|
|
949
|
-
for item_nodes in pending_ordered:
|
|
950
|
-
list_items.append({
|
|
951
|
-
"type": "list_item",
|
|
952
|
-
"content": [{"type": "paragraph", "content": item_nodes}],
|
|
953
|
-
})
|
|
954
|
-
self.draft_body["content"].append(
|
|
955
|
-
{"type": "ordered_list", "content": list_items}
|
|
956
|
-
)
|
|
957
|
-
pending_ordered.clear()
|
|
958
|
-
|
|
959
|
-
for line in text_content.split("\n"):
|
|
960
|
-
line = line.strip()
|
|
961
|
-
if not line:
|
|
962
|
-
flush_bullets()
|
|
963
|
-
flush_ordered()
|
|
964
|
-
flush_quotes()
|
|
965
|
-
continue
|
|
966
|
-
|
|
967
|
-
# Check for blockquote marker
|
|
968
|
-
if line.startswith("> ") or line == ">":
|
|
969
|
-
flush_bullets()
|
|
970
|
-
flush_ordered()
|
|
971
|
-
quote_text = line[2:] if line.startswith("> ") else ""
|
|
972
|
-
pending_quotes.append(quote_text)
|
|
973
|
-
continue
|
|
974
|
-
|
|
975
|
-
# Check for ordered list marker
|
|
976
|
-
ordered_match = re.match(r'^(\d+)\.\s+(.*)', line)
|
|
977
|
-
if ordered_match:
|
|
978
|
-
flush_bullets()
|
|
979
|
-
flush_quotes()
|
|
980
|
-
item_text = ordered_match.group(2).strip()
|
|
981
|
-
tokens = parse_inline(item_text)
|
|
982
|
-
text_nodes = tokens_to_text_nodes(tokens)
|
|
983
|
-
if text_nodes:
|
|
984
|
-
pending_ordered.append(text_nodes)
|
|
985
|
-
continue
|
|
986
|
-
|
|
987
|
-
# Check for bullet marker
|
|
988
|
-
bullet_text = None
|
|
989
|
-
if line.startswith("* "):
|
|
990
|
-
bullet_text = line[2:].strip()
|
|
991
|
-
elif line.startswith("- "):
|
|
992
|
-
bullet_text = line[2:].strip()
|
|
993
|
-
elif line.startswith("*") and not line.startswith("**"):
|
|
994
|
-
bullet_text = line[1:].strip()
|
|
995
|
-
|
|
996
|
-
if bullet_text is not None:
|
|
997
|
-
flush_ordered()
|
|
998
|
-
flush_quotes()
|
|
999
|
-
tokens = parse_inline(bullet_text)
|
|
1000
|
-
text_nodes = tokens_to_text_nodes(tokens)
|
|
1001
|
-
if text_nodes:
|
|
1002
|
-
pending_bullets.append(text_nodes)
|
|
1003
|
-
else:
|
|
1004
|
-
flush_bullets()
|
|
1005
|
-
flush_ordered()
|
|
1006
|
-
flush_quotes()
|
|
1007
|
-
tokens = parse_inline(line)
|
|
1008
|
-
self.add({"type": "paragraph", "content": tokens})
|
|
1009
|
-
|
|
1010
|
-
flush_bullets()
|
|
1011
|
-
flush_ordered()
|
|
1012
|
-
flush_quotes()
|
|
1013
|
-
else:
|
|
1014
|
-
# Single line — blockquote, ordered list, or paragraph
|
|
1015
|
-
if text_content.startswith("> ") or text_content == ">":
|
|
1016
|
-
quote_text = text_content[2:] if text_content.startswith("> ") else ""
|
|
1017
|
-
tokens = parse_inline(quote_text)
|
|
1018
|
-
text_nodes = tokens_to_text_nodes(tokens)
|
|
1019
|
-
para = {"type": "paragraph", "content": text_nodes} if text_nodes else {"type": "paragraph"}
|
|
1020
|
-
self.draft_body["content"] = self.draft_body.get("content", []) + [
|
|
1021
|
-
{"type": "blockquote", "content": [para]}
|
|
1022
|
-
]
|
|
1023
|
-
|
|
1024
|
-
elif re.match(r'^(\d+)\.\s+(.*)', text_content):
|
|
1025
|
-
ordered_match = re.match(r'^(\d+)\.\s+(.*)', text_content)
|
|
1026
|
-
item_text = ordered_match.group(2).strip()
|
|
1027
|
-
tokens = parse_inline(item_text)
|
|
1028
|
-
text_nodes = tokens_to_text_nodes(tokens)
|
|
1029
|
-
if text_nodes:
|
|
1030
|
-
list_item = {
|
|
1031
|
-
"type": "list_item",
|
|
1032
|
-
"content": [{"type": "paragraph", "content": text_nodes}],
|
|
1033
|
-
}
|
|
1034
|
-
self.draft_body["content"].append(
|
|
1035
|
-
{"type": "ordered_list", "content": [list_item]}
|
|
1036
|
-
)
|
|
1037
|
-
|
|
1038
|
-
else:
|
|
1039
|
-
tokens = parse_inline(text_content)
|
|
1040
|
-
self.add({"type": "paragraph", "content": tokens})
|
|
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])
|
|
632
|
+
from substack import mdrender
|
|
1048
633
|
|
|
634
|
+
rendered = mdrender.markdown_to_doc(markdown_content, api=api)
|
|
635
|
+
self.draft_body["content"] = self.draft_body.get("content", []) + rendered
|
|
1049
636
|
return self
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
substack/__init__.py,sha256=I3u5zUvxfPpbPYQTCV0VR3lGS4gyQQISC_0_GPN3FGk,390
|
|
2
|
-
substack/api.py,sha256=VPzQ_bpqGcE4ZXWD2lUppnvWsuLRjvtBN7bHmW4Bgww,20422
|
|
3
|
-
substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
|
|
4
|
-
substack/post.py,sha256=ZObXe62J7iXhSI_XBpadVTMXpN6xqLNHXUrOHSidkgM,39440
|
|
5
|
-
python_substack-0.1.23.dist-info/METADATA,sha256=90wvACIgduBlYv9RyB6euQq59V0ZJXgl39mB-isWWb8,9780
|
|
6
|
-
python_substack-0.1.23.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
7
|
-
python_substack-0.1.23.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
8
|
-
python_substack-0.1.23.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|