Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion substack/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
10 changes: 6 additions & 4 deletions substack/mdrender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean)
match = re.match(
r"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", 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:
Expand Down
14 changes: 14 additions & 0 deletions tests/substack/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
78 changes: 55 additions & 23 deletions tests/substack/test_widget_preservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<!-- python-substack-node:v1 {encoded} -->"

parsed = parse_node_marker(comment)
assert parsed == node

Expand Down Expand Up @@ -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"<!-- python-substack-node:v1 {btn_encoded} -->"

# 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"<!-- python-substack-node:v1 {recipe_encoded} -->"

markdown = f"""# Heading 1
Expand All @@ -83,11 +95,11 @@ def test_markdown_to_doc_preserves_block_and_inline_markers():
<!-- ordinary comment should be ignored -->
"""
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"]
Expand All @@ -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<!-- python-substack-node:v1 {btn_encoded} -->\n"
submitted_markdown = (
f"# New Title\n\n<!-- python-substack-node:v1 {btn_encoded} -->\n"
)

res = api.update_draft_from_markdown(42, submitted_markdown)
assert res["action"] == "update"
Expand All @@ -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
Loading