Skip to content

Commit a425e47

Browse files
authored
Merge pull request #78 from giacomo-folli/fix-drafts-list-response
Fix draft listing for current Substack API responses
2 parents bdb6018 + 2d4539c commit a425e47

5 files changed

Lines changed: 85 additions & 28 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Fixed
6+
7+
- Return draft records from the Substack drafts response envelope so `drafts list` works with the current API response.
8+
39
## 0.8.0
410

511
### Added

‎substack/api.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,10 @@ def get_drafts(self, filter=None, offset=None, limit=None):
432432
f"{self.publication_url}/drafts",
433433
params={"filter": filter, "offset": offset, "limit": limit},
434434
)
435-
return Api._handle_response(response=response)
435+
drafts = Api._handle_response(response=response)
436+
if isinstance(drafts, dict) and isinstance(drafts.get("posts"), list):
437+
return drafts["posts"]
438+
return drafts
436439

437440
def get_draft(self, draft_id):
438441
"""

‎substack/mdrender.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,21 @@
4646
def parse_node_marker(comment_content: str) -> dict | None:
4747
"""
4848
Parse a python-substack-node:v1 comment marker and return the parsed JSON dictionary.
49-
49+
5050
If it is not a python-substack-node:v1 marker, returns None.
5151
If it is an attempted marker but is corrupt/malformed, raises ValueError.
5252
"""
5353
clean = comment_content.strip()
54-
match = re.match(r"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean)
54+
match = re.match(
55+
r"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean
56+
)
5557
if not match:
5658
if "python-substack-node:v1" in clean:
5759
raise ValueError("Corrupt marker format")
5860
return None
59-
61+
6062
encoded = match.group(1)
61-
63+
6264
try:
6365
padding = len(encoded) % 4
6466
if padding:

‎tests/substack/test_api.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,20 @@ def test_unschedule_draft_deletes_scheduled_release(self):
129129
"https://writer.substack.com/api/v1/drafts/42/scheduled_release"
130130
)
131131

132+
def test_get_drafts_extracts_posts_from_response_envelope(self):
133+
api = Api.__new__(Api)
134+
api.publication_url = "https://writer.substack.com/api/v1"
135+
api._session = Mock()
136+
response = Mock(status_code=200)
137+
response.json.return_value = {
138+
"posts": [{"id": 42}],
139+
"hasMore": False,
140+
"nextCursor": None,
141+
}
142+
api._session.get.return_value = response
143+
144+
self.assertEqual(api.get_drafts(), [{"id": 42}])
145+
132146
@pytest.mark.live
133147
@_e2e
134148
def test_get_posts(self):

‎tests/substack/test_widget_preservation.py‎

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,20 @@
55
import pytest
66

77
from substack import Api, cli
8-
from substack.mdrender import parse_node_marker, markdown_to_doc
8+
from substack.mdrender import markdown_to_doc, parse_node_marker
99

1010

1111
def test_parse_node_marker_valid():
12-
node = {"type": "button", "attrs": {"text": "Click me", "url": "https://example.com"}}
13-
payload = json.dumps(node, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")
12+
node = {
13+
"type": "button",
14+
"attrs": {"text": "Click me", "url": "https://example.com"},
15+
}
16+
payload = json.dumps(
17+
node, ensure_ascii=False, separators=(",", ":"), sort_keys=True
18+
).encode("utf-8")
1419
encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
1520
comment = f"<!-- python-substack-node:v1 {encoded} -->"
16-
21+
1722
parsed = parse_node_marker(comment)
1823
assert parsed == node
1924

@@ -63,15 +68,22 @@ def test_parse_node_marker_ordinary_comment():
6368

6469
def test_markdown_to_doc_preserves_block_and_inline_markers():
6570
# Build a button node
66-
btn = {"type": "button", "attrs": {"text": "Click me", "url": "https://example.com"}}
71+
btn = {
72+
"type": "button",
73+
"attrs": {"text": "Click me", "url": "https://example.com"},
74+
}
6775
btn_payload = json.dumps(btn, separators=(",", ":"), sort_keys=True).encode("utf-8")
6876
btn_encoded = base64.urlsafe_b64encode(btn_payload).decode("ascii").rstrip("=")
6977
btn_comment = f"<!-- python-substack-node:v1 {btn_encoded} -->"
7078

7179
# Build an inline recipe node
7280
recipe = {"type": "recipe", "attrs": {"id": 123}}
73-
recipe_payload = json.dumps(recipe, separators=(",", ":"), sort_keys=True).encode("utf-8")
74-
recipe_encoded = base64.urlsafe_b64encode(recipe_payload).decode("ascii").rstrip("=")
81+
recipe_payload = json.dumps(recipe, separators=(",", ":"), sort_keys=True).encode(
82+
"utf-8"
83+
)
84+
recipe_encoded = (
85+
base64.urlsafe_b64encode(recipe_payload).decode("ascii").rstrip("=")
86+
)
7587
recipe_comment = f"<!-- python-substack-node:v1 {recipe_encoded} -->"
7688

7789
markdown = f"""# Heading 1
@@ -83,11 +95,11 @@ def test_markdown_to_doc_preserves_block_and_inline_markers():
8395
<!-- ordinary comment should be ignored -->
8496
"""
8597
doc = markdown_to_doc(markdown)
86-
98+
8799
# doc should have heading, paragraph (with text, recipe inline node, text), and the button block node
88100
assert len(doc) == 3
89101
assert doc[0]["type"] == "heading"
90-
102+
91103
p = doc[1]
92104
assert p["type"] == "paragraph"
93105
inline_content = p["content"]
@@ -105,24 +117,37 @@ def test_update_draft_from_markdown_preservation(monkeypatch):
105117
monkeypatch.setattr(api, "get_user_id", lambda: 1)
106118

107119
# Remote draft contains an unsupported "button" node
108-
remote_btn = {"type": "button", "attrs": {"text": "Click", "url": "https://example.com"}}
120+
remote_btn = {
121+
"type": "button",
122+
"attrs": {"text": "Click", "url": "https://example.com"},
123+
}
109124
remote_body = {
110125
"type": "doc",
111126
"content": [
112-
{"type": "heading", "attrs": {"level": 1}, "content": [{"type": "text", "text": "Hello"}]},
113-
remote_btn
114-
]
127+
{
128+
"type": "heading",
129+
"attrs": {"level": 1},
130+
"content": [{"type": "text", "text": "Hello"}],
131+
},
132+
remote_btn,
133+
],
115134
}
116-
mock_get_draft = Mock(return_value={"id": 42, "draft_body": json.dumps(remote_body)})
135+
mock_get_draft = Mock(
136+
return_value={"id": 42, "draft_body": json.dumps(remote_body)}
137+
)
117138
monkeypatch.setattr(api, "get_draft", mock_get_draft)
118-
139+
119140
mock_put_draft = Mock(return_value={"id": 42})
120141
monkeypatch.setattr(api, "put_draft", mock_put_draft)
121142

122143
# 1. Update with correct marker matches and succeeds
123-
btn_payload = json.dumps(remote_btn, separators=(",", ":"), sort_keys=True).encode("utf-8")
144+
btn_payload = json.dumps(remote_btn, separators=(",", ":"), sort_keys=True).encode(
145+
"utf-8"
146+
)
124147
btn_encoded = base64.urlsafe_b64encode(btn_payload).decode("ascii").rstrip("=")
125-
submitted_markdown = f"# New Title\n\n<!-- python-substack-node:v1 {btn_encoded} -->\n"
148+
submitted_markdown = (
149+
f"# New Title\n\n<!-- python-substack-node:v1 {btn_encoded} -->\n"
150+
)
126151

127152
res = api.update_draft_from_markdown(42, submitted_markdown)
128153
assert res["action"] == "update"
@@ -137,23 +162,30 @@ def test_update_draft_from_markdown_preservation(monkeypatch):
137162

138163
# 3. Update without marker succeeds when allow_unsupported_change is True
139164
mock_put_draft.reset_mock()
140-
res = api.update_draft_from_markdown(42, "# New Title without button", allow_unsupported_change=True)
165+
res = api.update_draft_from_markdown(
166+
42, "# New Title without button", allow_unsupported_change=True
167+
)
141168
assert res["action"] == "update"
142169
assert mock_put_draft.called
143170

144171

145-
def test_cli_update_requires_yes_for_allow_unsupported_change(tmp_path, monkeypatch, capsys):
172+
def test_cli_update_requires_yes_for_allow_unsupported_change(
173+
tmp_path, monkeypatch, capsys
174+
):
146175
# Mocking UpdateOperationsApi
147176
class MockApi:
148177
def update_draft_from_markdown(self, *args, **kwargs):
149178
return {"action": "update"}
150-
179+
151180
monkeypatch.setattr(cli, "_api_from_env", lambda **kw: MockApi())
152-
181+
153182
md_file = tmp_path / "test.md"
154183
md_file.write_text("# Test", encoding="utf-8")
155-
184+
156185
# If passing --allow-unsupported-change without --yes, it must raise CLIUsageError
157-
assert cli.main(["drafts", "update", "42", str(md_file), "--allow-unsupported-change"]) == 2
186+
assert (
187+
cli.main(["drafts", "update", "42", str(md_file), "--allow-unsupported-change"])
188+
== 2
189+
)
158190
err = capsys.readouterr().err
159191
assert "--yes is required when using --allow-unsupported-change" in err

0 commit comments

Comments
 (0)