From 1a73d612e112070a909b0183f47cf1062368b425 Mon Sep 17 00:00:00 2001 From: Giacomo Folli Date: Thu, 24 Sep 2026 14:08:46 +0200 Subject: [PATCH] fix: handle drafts response envelope --- CHANGELOG.md | 6 ++ substack/api.py | 5 +- substack/mdrender.py | 10 +-- tests/substack/test_api.py | 14 ++++ tests/substack/test_widget_preservation.py | 78 +++++++++++++++------- 5 files changed, 85 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85df484..dc8ec6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- Return draft records from the Substack drafts response envelope so `drafts list` works with the current API response. + ## 0.8.0 ### Added diff --git a/substack/api.py b/substack/api.py index 39849de..d266810 100644 --- a/substack/api.py +++ b/substack/api.py @@ -432,7 +432,10 @@ def get_drafts(self, filter=None, offset=None, limit=None): f"{self.publication_url}/drafts", params={"filter": filter, "offset": offset, "limit": limit}, ) - return Api._handle_response(response=response) + drafts = Api._handle_response(response=response) + if isinstance(drafts, dict) and isinstance(drafts.get("posts"), list): + return drafts["posts"] + return drafts def get_draft(self, draft_id): """ diff --git a/substack/mdrender.py b/substack/mdrender.py index 59f373f..740d95b 100644 --- a/substack/mdrender.py +++ b/substack/mdrender.py @@ -46,19 +46,21 @@ def parse_node_marker(comment_content: str) -> dict | None: """ Parse a python-substack-node:v1 comment marker and return the parsed JSON dictionary. - + If it is not a python-substack-node:v1 marker, returns None. If it is an attempted marker but is corrupt/malformed, raises ValueError. """ clean = comment_content.strip() - match = re.match(r"^$", clean) + match = re.match( + r"^$", clean + ) if not match: if "python-substack-node:v1" in clean: raise ValueError("Corrupt marker format") return None - + encoded = match.group(1) - + try: padding = len(encoded) % 4 if padding: diff --git a/tests/substack/test_api.py b/tests/substack/test_api.py index 76d8a5b..f0e71c9 100644 --- a/tests/substack/test_api.py +++ b/tests/substack/test_api.py @@ -129,6 +129,20 @@ def test_unschedule_draft_deletes_scheduled_release(self): "https://writer.substack.com/api/v1/drafts/42/scheduled_release" ) + def test_get_drafts_extracts_posts_from_response_envelope(self): + api = Api.__new__(Api) + api.publication_url = "https://writer.substack.com/api/v1" + api._session = Mock() + response = Mock(status_code=200) + response.json.return_value = { + "posts": [{"id": 42}], + "hasMore": False, + "nextCursor": None, + } + api._session.get.return_value = response + + self.assertEqual(api.get_drafts(), [{"id": 42}]) + @pytest.mark.live @_e2e def test_get_posts(self): diff --git a/tests/substack/test_widget_preservation.py b/tests/substack/test_widget_preservation.py index 02349c3..346b37f 100644 --- a/tests/substack/test_widget_preservation.py +++ b/tests/substack/test_widget_preservation.py @@ -5,15 +5,20 @@ import pytest from substack import Api, cli -from substack.mdrender import parse_node_marker, markdown_to_doc +from substack.mdrender import markdown_to_doc, parse_node_marker def test_parse_node_marker_valid(): - node = {"type": "button", "attrs": {"text": "Click me", "url": "https://example.com"}} - payload = json.dumps(node, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") + node = { + "type": "button", + "attrs": {"text": "Click me", "url": "https://example.com"}, + } + payload = json.dumps( + node, ensure_ascii=False, separators=(",", ":"), sort_keys=True + ).encode("utf-8") encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=") comment = f"" - + parsed = parse_node_marker(comment) assert parsed == node @@ -63,15 +68,22 @@ def test_parse_node_marker_ordinary_comment(): def test_markdown_to_doc_preserves_block_and_inline_markers(): # Build a button node - btn = {"type": "button", "attrs": {"text": "Click me", "url": "https://example.com"}} + btn = { + "type": "button", + "attrs": {"text": "Click me", "url": "https://example.com"}, + } btn_payload = json.dumps(btn, separators=(",", ":"), sort_keys=True).encode("utf-8") btn_encoded = base64.urlsafe_b64encode(btn_payload).decode("ascii").rstrip("=") btn_comment = f"" # Build an inline recipe node recipe = {"type": "recipe", "attrs": {"id": 123}} - recipe_payload = json.dumps(recipe, separators=(",", ":"), sort_keys=True).encode("utf-8") - recipe_encoded = base64.urlsafe_b64encode(recipe_payload).decode("ascii").rstrip("=") + recipe_payload = json.dumps(recipe, separators=(",", ":"), sort_keys=True).encode( + "utf-8" + ) + recipe_encoded = ( + base64.urlsafe_b64encode(recipe_payload).decode("ascii").rstrip("=") + ) recipe_comment = f"" markdown = f"""# Heading 1 @@ -83,11 +95,11 @@ def test_markdown_to_doc_preserves_block_and_inline_markers(): """ doc = markdown_to_doc(markdown) - + # doc should have heading, paragraph (with text, recipe inline node, text), and the button block node assert len(doc) == 3 assert doc[0]["type"] == "heading" - + p = doc[1] assert p["type"] == "paragraph" inline_content = p["content"] @@ -105,24 +117,37 @@ def test_update_draft_from_markdown_preservation(monkeypatch): monkeypatch.setattr(api, "get_user_id", lambda: 1) # Remote draft contains an unsupported "button" node - remote_btn = {"type": "button", "attrs": {"text": "Click", "url": "https://example.com"}} + remote_btn = { + "type": "button", + "attrs": {"text": "Click", "url": "https://example.com"}, + } remote_body = { "type": "doc", "content": [ - {"type": "heading", "attrs": {"level": 1}, "content": [{"type": "text", "text": "Hello"}]}, - remote_btn - ] + { + "type": "heading", + "attrs": {"level": 1}, + "content": [{"type": "text", "text": "Hello"}], + }, + remote_btn, + ], } - mock_get_draft = Mock(return_value={"id": 42, "draft_body": json.dumps(remote_body)}) + mock_get_draft = Mock( + return_value={"id": 42, "draft_body": json.dumps(remote_body)} + ) monkeypatch.setattr(api, "get_draft", mock_get_draft) - + mock_put_draft = Mock(return_value={"id": 42}) monkeypatch.setattr(api, "put_draft", mock_put_draft) # 1. Update with correct marker matches and succeeds - btn_payload = json.dumps(remote_btn, separators=(",", ":"), sort_keys=True).encode("utf-8") + btn_payload = json.dumps(remote_btn, separators=(",", ":"), sort_keys=True).encode( + "utf-8" + ) btn_encoded = base64.urlsafe_b64encode(btn_payload).decode("ascii").rstrip("=") - submitted_markdown = f"# New Title\n\n\n" + submitted_markdown = ( + f"# New Title\n\n\n" + ) res = api.update_draft_from_markdown(42, submitted_markdown) assert res["action"] == "update" @@ -137,23 +162,30 @@ def test_update_draft_from_markdown_preservation(monkeypatch): # 3. Update without marker succeeds when allow_unsupported_change is True mock_put_draft.reset_mock() - res = api.update_draft_from_markdown(42, "# New Title without button", allow_unsupported_change=True) + res = api.update_draft_from_markdown( + 42, "# New Title without button", allow_unsupported_change=True + ) assert res["action"] == "update" assert mock_put_draft.called -def test_cli_update_requires_yes_for_allow_unsupported_change(tmp_path, monkeypatch, capsys): +def test_cli_update_requires_yes_for_allow_unsupported_change( + tmp_path, monkeypatch, capsys +): # Mocking UpdateOperationsApi class MockApi: def update_draft_from_markdown(self, *args, **kwargs): return {"action": "update"} - + monkeypatch.setattr(cli, "_api_from_env", lambda **kw: MockApi()) - + md_file = tmp_path / "test.md" md_file.write_text("# Test", encoding="utf-8") - + # If passing --allow-unsupported-change without --yes, it must raise CLIUsageError - assert cli.main(["drafts", "update", "42", str(md_file), "--allow-unsupported-change"]) == 2 + assert ( + cli.main(["drafts", "update", "42", str(md_file), "--allow-unsupported-change"]) + == 2 + ) err = capsys.readouterr().err assert "--yes is required when using --allow-unsupported-change" in err