article 0.1.4__tar.gz → 0.1.6__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.
Files changed (30) hide show
  1. {article-0.1.4 → article-0.1.6}/PKG-INFO +7 -2
  2. {article-0.1.4 → article-0.1.6}/README.md +3 -1
  3. {article-0.1.4 → article-0.1.6}/article/__init__.py +2 -0
  4. {article-0.1.4 → article-0.1.6}/article/__main__.py +45 -7
  5. {article-0.1.4 → article-0.1.6}/article/adapters/substack.py +267 -32
  6. {article-0.1.4 → article-0.1.6}/article/base.py +8 -0
  7. {article-0.1.4 → article-0.1.6}/article/config.py +8 -1
  8. {article-0.1.4 → article-0.1.6}/article/engine.py +37 -2
  9. {article-0.1.4 → article-0.1.6}/pyproject.toml +4 -1
  10. {article-0.1.4 → article-0.1.6}/tests/test_cli.py +2 -2
  11. {article-0.1.4 → article-0.1.6}/tests/test_pipeline.py +22 -0
  12. {article-0.1.4 → article-0.1.6}/tests/test_substack.py +96 -0
  13. {article-0.1.4 → article-0.1.6}/tests/test_substack_live.py +12 -1
  14. {article-0.1.4 → article-0.1.6}/.editorconfig +0 -0
  15. {article-0.1.4 → article-0.1.6}/.env.example +0 -0
  16. {article-0.1.4 → article-0.1.6}/.gitattributes +0 -0
  17. {article-0.1.4 → article-0.1.6}/.github/workflows/ci.yml +0 -0
  18. {article-0.1.4 → article-0.1.6}/.gitignore +0 -0
  19. {article-0.1.4 → article-0.1.6}/LICENSE +0 -0
  20. {article-0.1.4 → article-0.1.6}/article/adapters/__init__.py +0 -0
  21. {article-0.1.4 → article-0.1.6}/article/adapters/dev_to.py +0 -0
  22. {article-0.1.4 → article-0.1.6}/article/adapters/hashnode.py +0 -0
  23. {article-0.1.4 → article-0.1.6}/article/adapters/medium.py +0 -0
  24. {article-0.1.4 → article-0.1.6}/article/md_source.py +0 -0
  25. {article-0.1.4 → article-0.1.6}/article/misc/CHANGELOG.md +0 -0
  26. {article-0.1.4 → article-0.1.6}/article/registry.py +0 -0
  27. {article-0.1.4 → article-0.1.6}/article/state.py +0 -0
  28. {article-0.1.4 → article-0.1.6}/article/util.py +0 -0
  29. {article-0.1.4 → article-0.1.6}/examples/article.example.json +0 -0
  30. {article-0.1.4 → article-0.1.6}/tests/conftest.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: article
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Tools to semi-automate the publication of articles
5
5
  Project-URL: Homepage, https://github.com/thorwhalen/article
6
6
  Project-URL: Repository, https://github.com/thorwhalen/article
@@ -26,6 +26,7 @@ Requires-Dist: pyyaml
26
26
  Provides-Extra: completion
27
27
  Requires-Dist: argcomplete; extra == 'completion'
28
28
  Provides-Extra: dev
29
+ Requires-Dist: pillow; extra == 'dev'
29
30
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
30
31
  Requires-Dist: pytest>=7.0; extra == 'dev'
31
32
  Requires-Dist: python-substack<0.11,>=0.10; extra == 'dev'
@@ -33,6 +34,8 @@ Requires-Dist: ruff>=0.1.0; extra == 'dev'
33
34
  Provides-Extra: docs
34
35
  Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
35
36
  Requires-Dist: sphinx>=6.0; extra == 'docs'
37
+ Provides-Extra: images
38
+ Requires-Dist: pillow; extra == 'images'
36
39
  Provides-Extra: substack
37
40
  Requires-Dist: markdown-it-py>=3; extra == 'substack'
38
41
  Requires-Dist: mdit-py-plugins>=0.4; extra == 'substack'
@@ -77,7 +80,7 @@ Image paths resolve against the Markdown file's directory. Before anything is up
77
80
 
78
81
  | Construct | Why | What to do |
79
82
  |---|---|---|
80
- | A table | Substack has no table node | `--tables code` keeps it as a monospace code block; or render it to an image (in Python, pass `tables=` a function returning the image Markdown) |
83
+ | A table | Substack has no table node | `--tables code` keeps it as a monospace code block; `--tables image` renders it to a PNG (needs `pip install 'article[images]'`); in Python, `tables=` also takes a function returning the image Markdown |
81
84
  | An image in a paragraph with text | the converter keeps only the alt text | give the image its own paragraph; caption goes in the title slot |
82
85
  | A missing local image | nothing to upload | fix the path |
83
86
  | A raw HTML block | the converter drops it | rewrite it in Markdown |
@@ -88,6 +91,8 @@ Image paths resolve against the Markdown file's directory. Before anything is up
88
91
  article draft-substack essay.md --dry-run # what survives, no credentials needed
89
92
  article draft-substack essay.md # create the draft; prints its edit link
90
93
  article draft-substack essay.md --tables code # keep tables as code blocks
94
+ article draft-substack essay.md --update # rewrite the draft the last run created (refuses if you edited it in Substack; --overwrite-edits to replace those edits)
95
+ article syndicate-secondary essay.md # refused until the Substack post is live (--allow-unpublished to override)
91
96
  ```
92
97
 
93
98
  The draft's future public URL (`<publication>/p/<slug>`) is recorded as the canonical URL, so `syndicate-secondary` can point the other platforms at it.
@@ -36,7 +36,7 @@ Image paths resolve against the Markdown file's directory. Before anything is up
36
36
 
37
37
  | Construct | Why | What to do |
38
38
  |---|---|---|
39
- | A table | Substack has no table node | `--tables code` keeps it as a monospace code block; or render it to an image (in Python, pass `tables=` a function returning the image Markdown) |
39
+ | A table | Substack has no table node | `--tables code` keeps it as a monospace code block; `--tables image` renders it to a PNG (needs `pip install 'article[images]'`); in Python, `tables=` also takes a function returning the image Markdown |
40
40
  | An image in a paragraph with text | the converter keeps only the alt text | give the image its own paragraph; caption goes in the title slot |
41
41
  | A missing local image | nothing to upload | fix the path |
42
42
  | A raw HTML block | the converter drops it | rewrite it in Markdown |
@@ -47,6 +47,8 @@ Image paths resolve against the Markdown file's directory. Before anything is up
47
47
  article draft-substack essay.md --dry-run # what survives, no credentials needed
48
48
  article draft-substack essay.md # create the draft; prints its edit link
49
49
  article draft-substack essay.md --tables code # keep tables as code blocks
50
+ article draft-substack essay.md --update # rewrite the draft the last run created (refuses if you edited it in Substack; --overwrite-edits to replace those edits)
51
+ article syndicate-secondary essay.md # refused until the Substack post is live (--allow-unpublished to override)
50
52
  ```
51
53
 
52
54
  The draft's future public URL (`<publication>/p/<slug>`) is recorded as the canonical URL, so `syndicate-secondary` can point the other platforms at it.
@@ -45,6 +45,7 @@ from .base import (
45
45
  ArticleError,
46
46
  ArticleValidationError,
47
47
  CanonicalUrlMissing,
48
+ CanonicalUrlNotLive,
48
49
  ConfigError,
49
50
  Phase,
50
51
  PlatformName,
@@ -122,6 +123,7 @@ __all__ = [
122
123
  "AdapterError",
123
124
  "UnknownPlatformError",
124
125
  "CanonicalUrlMissing",
126
+ "CanonicalUrlNotLive",
125
127
  # state
126
128
  "JsonStateStore",
127
129
  "default_state_store",
@@ -70,9 +70,14 @@ def syndicate_secondary(
70
70
  env_file: Optional[str] = None,
71
71
  state_path: Optional[str] = None,
72
72
  platforms: Optional[str] = None,
73
+ allow_unpublished: bool = False,
73
74
  json_out: bool = False,
74
75
  ):
75
- """Phase 2 — syndicate to secondary platforms, injecting canonical_url for SEO."""
76
+ """Phase 2 — syndicate to secondary platforms, injecting canonical_url for SEO.
77
+
78
+ Refused while the Substack post is still a draft (its URL would be a 404), unless
79
+ ``allow_unpublished``.
80
+ """
76
81
  settings, article, store = _prepare(
77
82
  article_path, env_file=env_file, state_path=state_path
78
83
  )
@@ -80,7 +85,11 @@ def syndicate_secondary(
80
85
  try:
81
86
  summary = run_sync(
82
87
  _syndicate_secondary(
83
- article, settings=settings, store=store, platforms=selected
88
+ article,
89
+ settings=settings,
90
+ store=store,
91
+ platforms=selected,
92
+ allow_unpublished=allow_unpublished,
84
93
  )
85
94
  )
86
95
  except ArticleError as e:
@@ -105,22 +114,41 @@ def draft_substack(
105
114
  state_path: Optional[str] = None,
106
115
  tables: Optional[str] = None,
107
116
  dry_run: bool = False,
117
+ update: bool = False,
118
+ overwrite_edits: bool = False,
108
119
  json_out: bool = False,
109
120
  ):
110
- """Create an UNPUBLISHED Substack draft from a Markdown file; never publishes."""
121
+ """Create an UNPUBLISHED Substack draft from a Markdown file; never publishes.
122
+
123
+ With ``update``, rewrite the draft this article's earlier run created (from the
124
+ state file) instead of creating another one.
125
+ """
111
126
  settings, article, store = _prepare(
112
127
  article_path, env_file=env_file, state_path=state_path
113
128
  )
114
129
  config = article.platforms.substack or SubstackConfig()
115
130
  if tables is not None:
116
131
  config = config.model_copy(update={"tables": tables})
132
+ earlier = store.get(article.slug, {}).get("primary", {}).get("detail", {})
133
+ if update:
134
+ if not earlier.get("draft_id"):
135
+ raise cw.CommandError(
136
+ f"--update: no earlier draft is recorded for {article.slug!r} in the "
137
+ f"state file; run once without --update first"
138
+ )
139
+ config = config.model_copy(
140
+ update={
141
+ "draft_id": earlier["draft_id"],
142
+ "expected_revision": earlier.get("revision"),
143
+ "overwrite_edits": overwrite_edits,
144
+ }
145
+ )
117
146
  try:
118
147
  if dry_run:
119
148
  report = _substack.dry_run(article, config=config)
120
149
  return json.dumps(report, indent=2) if json_out else _render_dry_run(report)
121
150
  platforms = article.platforms.model_copy(update={SUBSTACK: config})
122
151
  article = article.model_copy(update={"platforms": platforms})
123
- earlier = store.get(article.slug, {}).get("primary", {}).get("detail", {})
124
152
  summary = run_sync(_publish_primary(article, settings=settings, store=store))
125
153
  except ArticleError as e:
126
154
  raise cw.CommandError(str(e)) from e
@@ -131,7 +159,7 @@ def draft_substack(
131
159
  detail = summary.by_platform[SUBSTACK].detail
132
160
  lines = [summary.render(), f"Edit the draft: {detail.get('edit_url')}"]
133
161
  lines += [f" warning: {w}" for w in detail.get("warnings", ())]
134
- if earlier.get("edit_url"):
162
+ if earlier.get("edit_url") and not update:
135
163
  lines.append(
136
164
  f"Note: an earlier draft of this article is still on Substack "
137
165
  f"({earlier['edit_url']}); delete it if this one replaces it."
@@ -149,11 +177,21 @@ _dispatch_config = {
149
177
  command: {"article_path": {"help": "Path to the article JSON file"}}
150
178
  for command in ("publish-primary", "syndicate-secondary")
151
179
  }
180
+ _dispatch_config["syndicate-secondary"]["allow_unpublished"] = {
181
+ "help": "Syndicate even though the Substack post is still a draft (its URL is a 404)"
182
+ }
152
183
  _dispatch_config["draft-substack"] = {
153
184
  "article_path": {"help": "Path to the essay Markdown file (or article JSON)"},
154
185
  "tables": {
155
- "choices": ["error", "code"],
156
- "help": "Markdown tables: refuse (error, the default) or keep as code blocks",
186
+ "choices": ["error", "code", "image"],
187
+ "help": "Markdown tables: refuse (error, the default), keep as code blocks, "
188
+ "or render to an image (needs article[images])",
189
+ },
190
+ "update": {
191
+ "help": "Rewrite the draft an earlier run created instead of making a new one"
192
+ },
193
+ "overwrite_edits": {
194
+ "help": "With --update: replace edits made in the Substack editor since the last run"
157
195
  },
158
196
  "dry_run": {"help": "Check and convert only; no credentials, nothing sent"},
159
197
  }
@@ -14,19 +14,30 @@ that publishes. Review the draft in the Substack editor and publish it there.
14
14
  Before anything is uploaded, :func:`preflight` checks the Markdown for what
15
15
  the converter would lose silently, and refuses it with every problem listed:
16
16
 
17
- - **tables** (Substack has no table node): refused by default, or kept as a
18
- monospace code block with ``tables="code"``, or replaced by whatever a
19
- callable returns (e.g. a rendered image), never dropped;
17
+ - **tables** (Substack has no table node): refused by default, kept as a
18
+ monospace code block with ``tables="code"``, rendered to a PNG with
19
+ ``tables="image"`` (needs Pillow: ``pip install 'article[images]'``), or replaced
20
+ by whatever a callable returns, never dropped;
20
21
  - an **image sharing a paragraph with text** (the converter keeps only its
21
22
  alt text): put the image in its own paragraph, caption in the title slot;
22
23
  - a **local image that does not exist** (paths resolve against the article's
23
- ``assets_dir``, the source file's directory);
24
+ ``assets_dir``, the source file's directory) or that sits **inside a list, quote
25
+ or footnote** (Substack's editor is not known to accept those; move it to the top
26
+ level). Local image paths are rewritten to absolute ones, so conversion never
27
+ depends on, or changes, the process's working directory;
24
28
  - a **raw HTML block** (the converter drops it).
25
29
 
26
30
  The result's ``url`` is the post's public address once published
27
31
  (``<publication>/p/<slug>``), which the engine records as the canonical URL;
28
32
  ``detail["edit_url"]`` opens the draft in the editor.
29
33
 
34
+ **Updating in place.** With ``config.draft_id`` set (the CLI's ``--update`` fills it
35
+ from the state store), the existing draft is rewritten with python-substack's
36
+ ``update_draft_from_markdown`` instead of creating a new one. The draft's revision
37
+ (recorded when it was created or last updated) guards against clobbering edits made
38
+ in Substack's editor: a changed draft is refused unless ``config.overwrite_edits``.
39
+ The title is not updated in place.
40
+
30
41
  The client is a seam: pass ``api=`` (anything with python-substack's ``Api``
31
42
  methods) to :func:`create_substack_draft`; tests pass a fake.
32
43
  """
@@ -34,14 +45,15 @@ methods) to :func:`create_substack_draft`; tests pass a fake.
34
45
  from __future__ import annotations
35
46
 
36
47
  import asyncio
37
- import os
48
+ import hashlib
49
+ import json
38
50
  import re
39
- import threading
51
+ import struct
52
+ import tempfile
40
53
  from collections import Counter
41
- from contextlib import contextmanager
42
54
  from pathlib import Path
43
55
  from typing import Any, Callable, Iterator, Literal, Mapping, Optional, Union
44
- from urllib.parse import unquote, urlsplit
56
+ from urllib.parse import quote, unquote, urlsplit
45
57
 
46
58
  from ..base import SUBSTACK, AdapterError, PublishResult
47
59
  from ..config import Article, SubstackConfig
@@ -52,7 +64,7 @@ _log = get_logger()
52
64
 
53
65
  #: How a Markdown table is handled: refuse it, keep it as a code block, or
54
66
  #: replace it with the Markdown a callable returns (e.g. an image reference).
55
- TablePolicy = Union[Literal["error", "code"], Callable[[str], str]]
67
+ TablePolicy = Union[Literal["error", "code", "image"], Callable[[str], str]]
56
68
 
57
69
  _INSTALL_HINT = "install the Substack extra: pip install 'article[substack]'"
58
70
  _AUTH_HINT = (
@@ -94,6 +106,61 @@ def _code_table(table_markdown: str) -> str:
94
106
  return f"```\n{table_markdown.rstrip()}\n```\n"
95
107
 
96
108
 
109
+ def _split_row(line: str) -> list[str]:
110
+ cells = re.split(r"(?<!\\)\|", line.strip().strip("|"))
111
+ return [c.strip().replace("\\|", "|") for c in cells]
112
+
113
+
114
+ def render_table_image(
115
+ table_markdown: str, *, out_dir: Optional[str] = None, scale: int = 2
116
+ ) -> str:
117
+ """Render a Markdown table to a PNG and return the Markdown image that shows it.
118
+
119
+ The file name is the content hash, so re-running reuses the file. Needs Pillow.
120
+ ``out_dir`` defaults to a folder under the system temp directory.
121
+ """
122
+ try:
123
+ from PIL import Image, ImageDraw, ImageFont
124
+ except ImportError as e:
125
+ raise AdapterError(
126
+ "substack: tables='image' needs Pillow: pip install 'article[images]'"
127
+ ) from e
128
+ rows = [
129
+ _split_row(line)
130
+ for line in table_markdown.strip().splitlines()
131
+ if not re.fullmatch(r"\s*\|?[\s:|-]+\|?\s*", line) or "-" not in line
132
+ ]
133
+ width = max(len(r) for r in rows)
134
+ rows = [r + [""] * (width - len(r)) for r in rows]
135
+ font = ImageFont.load_default(size=14 * scale)
136
+ pad = 6 * scale
137
+ probe = ImageDraw.Draw(Image.new("RGB", (1, 1)))
138
+ col_w = [
139
+ max(probe.textlength(row[c], font=font) for row in rows) + 2 * pad
140
+ for c in range(width)
141
+ ]
142
+ row_h = int(14 * scale * 1.4) + 2 * pad
143
+ image = Image.new("RGB", (int(sum(col_w)) + 1, row_h * len(rows) + 1), "white")
144
+ draw = ImageDraw.Draw(image)
145
+ for r, row in enumerate(rows):
146
+ x = 0.0
147
+ for c, cell in enumerate(row):
148
+ box = (x, r * row_h, x + col_w[c], (r + 1) * row_h)
149
+ if r == 0:
150
+ draw.rectangle(box, fill="#eeeeee")
151
+ draw.rectangle(box, outline="#999999")
152
+ draw.text((x + pad, r * row_h + pad), cell, fill="black", font=font)
153
+ x += col_w[c]
154
+ folder = (
155
+ Path(out_dir) if out_dir else Path(tempfile.gettempdir()) / "article-tables"
156
+ )
157
+ folder.mkdir(parents=True, exist_ok=True)
158
+ digest = hashlib.sha256(table_markdown.encode("utf-8")).hexdigest()[:16]
159
+ path = folder / f"table-{digest}.png"
160
+ image.save(path)
161
+ return f"![table]({quote(str(path))})\n"
162
+
163
+
97
164
  def _replace_tables(
98
165
  markdown: str, tokens: list, policy: TablePolicy, problems: list[str]
99
166
  ) -> str:
@@ -103,10 +170,11 @@ def _replace_tables(
103
170
  for t in tables:
104
171
  problems.append(
105
172
  f"{_where(t, lines)}: a table (Substack has no table; pass "
106
- f"tables='code' to keep it as a code block, or render it to an image)"
173
+ f"tables='code' to keep it as a code block, or tables='image' to "
174
+ f"render it to a picture)"
107
175
  )
108
176
  return markdown
109
- render = _code_table if policy == "code" else policy
177
+ render = {"code": _code_table, "image": render_table_image}.get(policy, policy)
110
178
  if not callable(render):
111
179
  raise AdapterError(f"substack: unknown tables policy {policy!r}")
112
180
  for t in reversed(tables): # bottom-up, so earlier line numbers stay valid
@@ -139,9 +207,20 @@ def _image_problems(
139
207
  f"text, and Substack would keep only its alt text; give the image "
140
208
  f'its own paragraph and put the caption in the title: ![alt](src "caption")'
141
209
  )
210
+ nested = (
211
+ in_paragraph
212
+ and _is_image_only(tok.children)
213
+ and (tokens[i - 1].level > 0 or not tok.map)
214
+ )
142
215
  for img in images:
143
216
  src = img.attrs.get("src", "")
144
- if src.lower().startswith("data:"):
217
+ if nested and _is_local(src) and not src.lower().startswith("data:"):
218
+ yield (
219
+ f"{line}: a local image inside a list, quote or footnote "
220
+ f"({src}); Substack's editor is not known to accept those, so "
221
+ f"move it to the top level"
222
+ )
223
+ elif src.lower().startswith("data:"):
145
224
  yield f"{line}: an inline data: image cannot be uploaded; save it as a file"
146
225
  elif (
147
226
  _is_local(src)
@@ -150,6 +229,38 @@ def _image_problems(
150
229
  yield f"{line}: image file not found: {src} (looked in {base})"
151
230
 
152
231
 
232
+ def _absolute_images(markdown: str, tokens: list, assets_dir: Optional[str]) -> str:
233
+ """Rewrite each top-level image-only paragraph's local source to an absolute path.
234
+
235
+ python-substack resolves local image paths against the process's working
236
+ directory; absolute paths make conversion independent of it. Only images that
237
+ passed :func:`_image_problems` (so image-only top-level paragraphs) get here.
238
+ """
239
+ base = Path(assets_dir) if assets_dir else Path.cwd()
240
+ lines = markdown.splitlines(keepends=True)
241
+ edits = []
242
+ for i, tok in enumerate(tokens):
243
+ if tok.type != "inline" or not tok.children or not tok.map:
244
+ continue
245
+ kids = [c for c in tok.children if c.type != "softbreak"]
246
+ image = next((c for c in kids if c.type == "image"), None)
247
+ if image is None or not _is_local(image.attrs.get("src", "")):
248
+ continue
249
+ if image.attrs["src"].lower().startswith("data:"):
250
+ continue
251
+ path = (base / Path(unquote(image.attrs["src"])).expanduser()).resolve()
252
+ alt = image.content.replace("]", "\\]")
253
+ title = image.attrs.get("title")
254
+ title_part = f' "{title.replace(chr(34), chr(92) + chr(34))}"' if title else ""
255
+ text = f"![{alt}](<{quote(str(path))}>{title_part})"
256
+ if kids[0].type == "link_open":
257
+ text = f"[{text}]({kids[0].attrs.get('href', '')})"
258
+ edits.append((tok.map, text + "\n"))
259
+ for (start, end), text in reversed(edits): # bottom-up keeps line numbers valid
260
+ lines[start:end] = [text]
261
+ return "".join(lines)
262
+
263
+
153
264
  def _is_image_only(children: list) -> bool:
154
265
  kids = [c for c in children if c.type != "softbreak"]
155
266
  if len(kids) == 1 and kids[0].type == "image":
@@ -251,6 +362,7 @@ def preflight(
251
362
  f"substack: {len(problems)} problem(s) would lose content in the draft:\n"
252
363
  + "\n".join(f" - {p}" for p in problems)
253
364
  )
365
+ markdown = _absolute_images(markdown, tokens, assets_dir)
254
366
  return markdown, list(_html_inline_warnings(tokens, lines))
255
367
 
256
368
 
@@ -259,26 +371,67 @@ def preflight(
259
371
  # --------------------------------------------------------------------------- #
260
372
 
261
373
 
262
- _CWD_LOCK = threading.Lock()
374
+ def image_size(path: Union[str, Path]) -> Optional[tuple[int, int]]:
375
+ """``(width, height)`` of a PNG, GIF or JPEG file read from its header, else ``None``.
263
376
 
377
+ Stdlib only; other formats are measured with Pillow when it is installed.
378
+ """
379
+ with open(path, "rb") as f:
380
+ head = f.read(26)
381
+ if head[:8] == b"\x89PNG\r\n\x1a\n":
382
+ return struct.unpack(">II", head[16:24])
383
+ if head[:6] in (b"GIF87a", b"GIF89a"):
384
+ return struct.unpack("<HH", head[6:10])
385
+ if head[:2] == b"\xff\xd8":
386
+ f.seek(2)
387
+ while True:
388
+ marker = f.read(4)
389
+ if len(marker) < 4 or marker[0] != 0xFF:
390
+ break
391
+ code, length = marker[1], struct.unpack(">H", marker[2:4])[0]
392
+ if 0xC0 <= code <= 0xCF and code not in (0xC4, 0xC8, 0xCC):
393
+ h, w = struct.unpack(">HH", f.read(5)[1:5])
394
+ return w, h
395
+ f.seek(length - 2, 1)
396
+ try:
397
+ from PIL import Image
398
+
399
+ with Image.open(path) as im:
400
+ return im.size
401
+ except Exception:
402
+ return None
264
403
 
265
- @contextmanager
266
- def _working_directory(path: Optional[str]) -> Iterator[None]:
267
- """python-substack resolves local image paths against the cwd.
268
404
 
269
- The cwd is process-wide, so conversions are serialized by a lock; a server
270
- that converts concurrently should first make image paths absolute instead.
405
+ class _MeasuringApi:
406
+ """Wraps a python-substack ``Api``, remembering the pixel size of each upload.
407
+
408
+ python-substack gives every image node a default 1456x819; the sizes
409
+ recorded here are written back by :func:`_apply_image_sizes`.
271
410
  """
272
- if not path:
273
- yield
274
- return
275
- with _CWD_LOCK:
276
- previous = os.getcwd()
277
- os.chdir(path)
278
- try:
279
- yield
280
- finally:
281
- os.chdir(previous)
411
+
412
+ def __init__(self, api: Any):
413
+ self._api = api
414
+ self.sizes: dict[str, tuple[int, int]] = {}
415
+
416
+ def get_image(self, path):
417
+ uploaded = self._api.get_image(path)
418
+ size = image_size(path)
419
+ if size and isinstance(uploaded, Mapping) and uploaded.get("url"):
420
+ self.sizes[uploaded["url"]] = size
421
+ return uploaded
422
+
423
+ def __getattr__(self, name):
424
+ return getattr(self._api, name)
425
+
426
+
427
+ def _apply_image_sizes(node: Any, sizes: Mapping[str, tuple[int, int]]) -> None:
428
+ if isinstance(node, dict):
429
+ attrs = node.get("attrs")
430
+ if node.get("type") == "image2" and isinstance(attrs, dict):
431
+ if attrs.get("src") in sizes:
432
+ attrs["width"], attrs["height"] = sizes[attrs["src"]]
433
+ for child in node.get("content") or []:
434
+ _apply_image_sizes(child, sizes)
282
435
 
283
436
 
284
437
  def draft_document(
@@ -291,7 +444,8 @@ def draft_document(
291
444
  ) -> dict:
292
445
  """The draft payload python-substack posts (``draft_body`` is a JSON string).
293
446
 
294
- With ``api=None`` nothing is uploaded and local image paths stay as they are.
447
+ ``markdown`` is what :func:`preflight` returned (its local image paths are
448
+ absolute). With ``api=None`` nothing is uploaded.
295
449
  """
296
450
  try:
297
451
  from substack.post import Post
@@ -303,9 +457,14 @@ def draft_document(
303
457
  user_id=user_id,
304
458
  audience=config.audience,
305
459
  )
306
- with _working_directory(article.assets_dir):
307
- post.from_markdown(markdown, api=api)
308
- return post.get_draft()
460
+ measuring = _MeasuringApi(api) if api is not None else None
461
+ post.from_markdown(markdown, api=measuring)
462
+ draft = post.get_draft()
463
+ if measuring and measuring.sizes:
464
+ body = json.loads(draft["draft_body"])
465
+ _apply_image_sizes(body, measuring.sizes)
466
+ draft["draft_body"] = json.dumps(body)
467
+ return draft
309
468
 
310
469
 
311
470
  def make_substack_api(
@@ -423,6 +582,10 @@ def create_substack_draft(
423
582
  api = make_substack_api(secrets, publication_url=configured_url)
424
583
  publication = _publication_base(api, configured_url)
425
584
 
585
+ if config.draft_id is not None:
586
+ return _update_draft(
587
+ article, markdown, warnings, config=config, api=api, publication=publication
588
+ )
426
589
  draft = draft_document(
427
590
  article, markdown, config=config, api=api, user_id=api.get_user_id()
428
591
  )
@@ -465,12 +628,84 @@ def create_substack_draft(
465
628
  detail={
466
629
  "draft_id": draft_id,
467
630
  "edit_url": edit_url,
631
+ "revision": _revision_of(api, draft_id),
468
632
  "counts": summarize_document(draft),
469
633
  "warnings": warnings,
470
634
  },
471
635
  )
472
636
 
473
637
 
638
+ def _revision_of(api: Any, draft_id: Any) -> Optional[dict]:
639
+ """The draft's current revision (python-substack's conflict-check fingerprint), or None."""
640
+ try:
641
+ from substack.revision import compute_draft_revision
642
+
643
+ return compute_draft_revision(api.get_draft(draft_id))
644
+ except (
645
+ Exception
646
+ ): # an unreadable revision only makes the next update ask to overwrite
647
+ return None
648
+
649
+
650
+ def _update_draft(
651
+ article: Article,
652
+ markdown: str,
653
+ warnings: list[str],
654
+ *,
655
+ config: SubstackConfig,
656
+ api: Any,
657
+ publication: str,
658
+ ) -> PublishResult:
659
+ """Rewrite the existing draft ``config.draft_id`` from ``markdown``, never publishing."""
660
+ draft_id = config.draft_id
661
+ edit_url = f"{publication}/publish/post/{draft_id}"
662
+ tags = coalesce(config.tags, article.tags)
663
+ try:
664
+ result = api.update_draft_from_markdown(
665
+ draft_id,
666
+ markdown,
667
+ subtitle=article.subtitle or "",
668
+ audience=config.audience,
669
+ search_engine_description=article.description,
670
+ slug=article.slug,
671
+ draft_section_id=config.section_id,
672
+ tags=list(tags) if tags else None,
673
+ expected_revision=config.expected_revision,
674
+ allow_image_replacement=True, # the Markdown is the source of truth
675
+ allow_conflict=config.overwrite_edits,
676
+ )
677
+ except Exception as e:
678
+ hint = (
679
+ " (edited in Substack since the last run; pass overwrite_edits / "
680
+ "--overwrite-edits to replace those edits)"
681
+ if type(e).__name__ == "DraftConflictError"
682
+ else ""
683
+ )
684
+ raise AdapterError(
685
+ f"substack: updating draft {draft_id} ({edit_url}) failed: {e}{hint}"
686
+ ) from e
687
+ slug = ((result or {}).get("draft") or {}).get("slug") or article.slug
688
+ if slug != article.slug:
689
+ warnings.append(f"Substack changed the slug from {article.slug!r} to {slug!r}")
690
+ warnings.append(
691
+ "the title is not updated in place; change it in the Substack editor"
692
+ )
693
+ public_url = f"{publication}/p/{slug}"
694
+ return PublishResult.success(
695
+ SUBSTACK,
696
+ url=public_url,
697
+ status="draft",
698
+ canonical_url=public_url,
699
+ detail={
700
+ "draft_id": draft_id,
701
+ "edit_url": edit_url,
702
+ "updated_in_place": True,
703
+ "revision": _revision_of(api, draft_id),
704
+ "warnings": warnings,
705
+ },
706
+ )
707
+
708
+
474
709
  @register_adapter(SUBSTACK)
475
710
  async def publish(
476
711
  article: Article,
@@ -286,3 +286,11 @@ class UnknownPlatformError(ArticleError, KeyError):
286
286
 
287
287
  class CanonicalUrlMissing(ArticleError):
288
288
  """Phase 2 was requested before phase 1 recorded a canonical_url."""
289
+
290
+
291
+ class CanonicalUrlNotLive(ArticleError):
292
+ """Phase 2 was requested while the primary post is still an unpublished draft.
293
+
294
+ The recorded canonical URL is the post's *future* address, so syndicating now
295
+ would point other platforms at a 404.
296
+ """
@@ -73,7 +73,14 @@ class SubstackConfig(_PlatformConfig):
73
73
  #: What to do with a Markdown table, which Substack cannot represent:
74
74
  #: ``"error"`` (refuse, naming every table) or ``"code"`` (keep it as a
75
75
  #: monospace code block). Never dropped silently.
76
- tables: Literal["error", "code"] = "error"
76
+ tables: Literal["error", "code", "image"] = "error"
77
+ #: Update this existing draft in place instead of creating a new one (the CLI's
78
+ #: ``--update`` fills it, and ``expected_revision``, from the state store).
79
+ draft_id: Optional[int] = None
80
+ #: The draft's revision as last recorded; a draft edited since is refused.
81
+ expected_revision: Optional[dict] = None
82
+ #: Overwrite edits made in the Substack editor since ``expected_revision``.
83
+ overwrite_edits: bool = False
77
84
 
78
85
 
79
86
  class MediumConfig(_PlatformConfig):
@@ -35,6 +35,7 @@ from .base import (
35
35
  SECONDARY_PLATFORMS,
36
36
  AdapterError,
37
37
  CanonicalUrlMissing,
38
+ CanonicalUrlNotLive,
38
39
  Phase,
39
40
  PlatformName,
40
41
  PublishResult,
@@ -47,6 +48,21 @@ from .util import get_logger
47
48
 
48
49
  AdapterResolver = Callable[[str], PublishAdapter]
49
50
 
51
+ #: Seconds the draft-liveness GET may take before the URL counts as not live.
52
+ DFLT_LIVE_TIMEOUT = 10.0
53
+
54
+
55
+ def url_is_live(url: str, *, timeout: float = DFLT_LIVE_TIMEOUT) -> bool:
56
+ """Whether one GET of ``url`` answers 2xx (redirects followed); any failure is False."""
57
+ import urllib.request
58
+
59
+ request = urllib.request.Request(url, headers={"User-Agent": "article-pipeline"})
60
+ try:
61
+ with urllib.request.urlopen(request, timeout=timeout) as response:
62
+ return 200 <= response.status < 300
63
+ except Exception:
64
+ return False
65
+
50
66
 
51
67
  @dataclass
52
68
  class PipelineEngine:
@@ -60,6 +76,9 @@ class PipelineEngine:
60
76
  settings: Settings
61
77
  store: MutableMapping
62
78
  resolve_adapter: AdapterResolver = field(default=get_adapter)
79
+ #: ``url -> bool``: is the canonical URL live? Asked in phase 2 only while the
80
+ #: primary post is still a draft. Defaults to one HTTP GET (:func:`url_is_live`).
81
+ is_live: Callable[[str], bool] = field(default=url_is_live)
63
82
 
64
83
  def __post_init__(self) -> None:
65
84
  self._log = get_logger()
@@ -118,11 +137,14 @@ class PipelineEngine:
118
137
  article: Article,
119
138
  *,
120
139
  platforms: Optional[Iterable[PlatformName]] = None,
140
+ allow_unpublished: bool = False,
121
141
  ) -> RunSummary:
122
142
  """Syndicate to each configured secondary, injecting the canonical URL.
123
143
 
124
144
  Raises :class:`~article.base.CanonicalUrlMissing` if phase 1 hasn't
125
- recorded a canonical URL for this slug yet.
145
+ recorded a canonical URL for this slug yet, and
146
+ :class:`~article.base.CanonicalUrlNotLive` if the primary post is still a
147
+ draft whose URL does not resolve (``allow_unpublished=True`` skips the check).
126
148
  """
127
149
  canonical_url = self.store.get_canonical_url(article.slug)
128
150
  if not canonical_url:
@@ -131,6 +153,16 @@ class PipelineEngine:
131
153
  f"Run `publish-primary` first so the SSOT URL exists."
132
154
  )
133
155
 
156
+ primary = self.store.get(article.slug, {}).get("primary", {})
157
+ if primary.get("status") == "draft" and not allow_unpublished:
158
+ if not self.is_live(canonical_url):
159
+ raise CanonicalUrlNotLive(
160
+ f"The primary post for {article.slug!r} is still a draft: "
161
+ f"{canonical_url} does not resolve yet, so syndicating now would "
162
+ f"point other platforms at a 404. Publish it from the Substack "
163
+ f"editor first (or pass allow_unpublished to override)."
164
+ )
165
+
134
166
  targets = self._secondary_targets(article, platforms)
135
167
  if not targets:
136
168
  self._log.warning("No secondary platforms configured for %r.", article.slug)
@@ -258,7 +290,10 @@ async def syndicate_secondary(
258
290
  settings: Optional[Settings] = None,
259
291
  store: Optional[MutableMapping] = None,
260
292
  platforms: Optional[Iterable[PlatformName]] = None,
293
+ allow_unpublished: bool = False,
261
294
  ) -> RunSummary:
262
295
  """Phase 2 facade: syndicate to secondaries with the canonical_url injected."""
263
296
  engine = build_engine(settings=settings, store=store)
264
- return await engine.syndicate_secondary(article, platforms=platforms)
297
+ return await engine.syndicate_secondary(
298
+ article, platforms=platforms, allow_unpublished=allow_unpublished
299
+ )
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "article"
9
- version = "0.1.4"
9
+ version = "0.1.6"
10
10
  description = "Tools to semi-automate the publication of articles"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -57,6 +57,8 @@ substack = [
57
57
  "markdown-it-py>=3",
58
58
  "mdit-py-plugins>=0.4",
59
59
  ]
60
+ # tables="image": render Markdown tables to PNGs.
61
+ images = ["pillow"]
60
62
  # Shell tab-completion for the CLI.
61
63
  completion = [
62
64
  "argcomplete",
@@ -66,6 +68,7 @@ dev = [
66
68
  "pytest-cov>=4.0",
67
69
  "ruff>=0.1.0",
68
70
  "python-substack>=0.10,<0.11",
71
+ "pillow",
69
72
  ]
70
73
  docs = [
71
74
  "sphinx>=6.0",
@@ -82,7 +82,7 @@ def test_draft_substack_usage_line(subparsers):
82
82
  usage = " ".join(subparsers["draft-substack"].format_usage().split())
83
83
  assert usage == (
84
84
  "usage: article draft-substack [-h] [-e ENV_FILE] [-s STATE_PATH]"
85
- " [-t {error,code}] [-d] [-j] article-path"
85
+ " [-t {error,code,image}] [-d] [-u] [-o] [-j] article-path"
86
86
  )
87
87
 
88
88
 
@@ -97,7 +97,7 @@ def test_draft_substack_usage_line(subparsers):
97
97
  (
98
98
  "syndicate-secondary",
99
99
  "usage: article syndicate-secondary [-h] [-e ENV_FILE] [-s STATE_PATH]"
100
- " [-p PLATFORMS] [-j] article-path",
100
+ " [-p PLATFORMS] [-a] [-j] article-path",
101
101
  ),
102
102
  ],
103
103
  )
@@ -93,6 +93,7 @@ def _engine(tmp_path, *, resolve_adapter=get_adapter) -> PipelineEngine:
93
93
  settings=settings,
94
94
  store=store,
95
95
  resolve_adapter=_with_fake_substack(resolve_adapter),
96
+ is_live=lambda url: True, # no network: the draft counts as published
96
97
  )
97
98
 
98
99
 
@@ -256,3 +257,24 @@ def test_unconfigured_secondary_is_not_attempted(tmp_path):
256
257
  asyncio.run(engine.publish_primary(art))
257
258
  summary = asyncio.run(engine.syndicate_secondary(art))
258
259
  assert {r.platform for r in summary.results} == {MEDIUM}
260
+
261
+
262
+ def test_phase2_refuses_while_the_substack_post_is_still_a_draft(tmp_path):
263
+ from article.base import CanonicalUrlNotLive
264
+
265
+ engine = _engine(tmp_path)
266
+ engine.is_live = lambda url: False
267
+ art = _article()
268
+ asyncio.run(engine.publish_primary(art))
269
+ with pytest.raises(CanonicalUrlNotLive, match="still a draft"):
270
+ asyncio.run(engine.syndicate_secondary(art))
271
+ summary = asyncio.run(engine.syndicate_secondary(art, allow_unpublished=True))
272
+ assert summary.canonical_url
273
+ engine.is_live = lambda url: True
274
+ assert asyncio.run(engine.syndicate_secondary(art)).canonical_url
275
+
276
+
277
+ def test_url_is_live_is_false_for_an_unreachable_address():
278
+ from article.engine import url_is_live
279
+
280
+ assert url_is_live("http://127.0.0.1:9/never", timeout=0.2) is False
@@ -15,6 +15,8 @@ import os
15
15
  import re
16
16
  import subprocess
17
17
  import sys
18
+ from pathlib import Path
19
+ from urllib.parse import unquote
18
20
 
19
21
  import pytest
20
22
 
@@ -362,3 +364,97 @@ def test_a_title_without_ascii_asks_for_a_slug(tmp_path):
362
364
  (tmp_path / "x.md").write_text("# 日本語\n\nx\n", encoding="utf-8")
363
365
  with pytest.raises(Exception, match="add a 'slug:'"):
364
366
  load_article(str(tmp_path / "x.md"))
367
+
368
+
369
+ def test_uploaded_images_keep_their_real_pixel_size(tmp_path, fake_api):
370
+ import struct, zlib
371
+
372
+ def png(w, h):
373
+ def chunk(t, d):
374
+ return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d))
375
+
376
+ raw = b"".join(b"\x00" + b"\x00\x00\x00" * w for _ in range(h))
377
+ return (
378
+ b"\x89PNG\r\n\x1a\n"
379
+ + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
380
+ + chunk(b"IDAT", zlib.compress(raw))
381
+ + chunk(b"IEND", b"")
382
+ )
383
+
384
+ (tmp_path / "wide.png").write_bytes(png(30, 10))
385
+ (tmp_path / "tall.png").write_bytes(png(7, 21))
386
+ (tmp_path / "a.md").write_text("# T\n\n![w](wide.png)\n\n![t](tall.png)\n")
387
+ create_substack_draft(load_article(str(tmp_path / "a.md")), api=fake_api)
388
+ sizes = [
389
+ (n["attrs"]["width"], n["attrs"]["height"])
390
+ for n in _of_type(_body(fake_api), "image2")
391
+ ]
392
+ assert sizes == [(30, 10), (7, 21)]
393
+
394
+
395
+ # --------------------------------------------------------------------------- #1-#5 follow-ups
396
+
397
+
398
+ def test_conversion_does_not_touch_the_working_directory(essay_dir, fake_api, monkeypatch):
399
+ """Local image paths become absolute during preflight, so no process-wide chdir."""
400
+ seen = []
401
+ real_chdir = os.chdir
402
+ monkeypatch.setattr(os, "chdir", lambda p: (seen.append(p), real_chdir(p)))
403
+ monkeypatch.chdir(essay_dir.parent)
404
+ seen.clear()
405
+ art = load_article(str(essay_dir / "essay.md"))
406
+ create_substack_draft(art, api=fake_api)
407
+ assert seen == []
408
+ assert fake_api.uploaded == [str((essay_dir / "images" / "fig 1.png").resolve())]
409
+
410
+
411
+ def test_preflight_makes_local_image_paths_absolute(tmp_path, png_bytes):
412
+ (tmp_path / "a b.png").write_bytes(png_bytes)
413
+ md, _ = preflight('![alt](<a b.png> "cap")\n', assets_dir=str(tmp_path))
414
+ assert str(tmp_path.resolve()) in unquote(md) and 'a%20b.png' in md and '"cap"' in md
415
+
416
+
417
+ def test_a_local_image_inside_a_list_is_refused(tmp_path, png_bytes):
418
+ (tmp_path / "a.png").write_bytes(png_bytes)
419
+ with pytest.raises(AdapterError, match="inside a list, quote or footnote"):
420
+ preflight("- ![alt](a.png)\n", assets_dir=str(tmp_path))
421
+ # a remote one is fine
422
+ preflight("- ![alt](https://example.com/a.png)\n", assets_dir=str(tmp_path))
423
+
424
+
425
+ def test_tables_image_policy_renders_a_png(tmp_path):
426
+ pytest.importorskip("PIL")
427
+ md, _ = preflight(
428
+ "| a | b |\n|---|---|\n| 1 | 2 |\n", tables="image", assets_dir=str(tmp_path)
429
+ )
430
+ assert md.startswith("![table](")
431
+ path = Path(unquote(md[len("![table](") : md.index(")")]).strip("<>"))
432
+ assert path.is_file() and path.read_bytes()[:4] == b"\x89PNG"
433
+
434
+
435
+ def test_a_draft_is_updated_in_place_with_its_revision_guard(essay_dir, fake_api):
436
+ calls = []
437
+ fake_api.update_draft_from_markdown = lambda draft_id, md, **kw: (
438
+ calls.append((draft_id, md, kw)) or {"draft": {"slug": "the-agile-religion"}}
439
+ )
440
+ art = load_article(str(essay_dir / "essay.md"))
441
+ config = SubstackConfig(draft_id=1234, expected_revision={"revision": "abc"})
442
+ result = create_substack_draft(art, config=config, api=fake_api)
443
+ ((draft_id, md, kwargs),) = calls
444
+ assert draft_id == 1234 and kwargs["expected_revision"] == {"revision": "abc"}
445
+ assert kwargs["allow_conflict"] is False and kwargs["slug"] == "the-agile-religion"
446
+ assert fake_api.posted == [] # no new draft
447
+ assert result.detail["updated_in_place"] and result.status == "draft"
448
+
449
+
450
+ def test_an_edited_draft_is_refused_with_a_hint(essay_dir, fake_api):
451
+ class DraftConflictError(ValueError):
452
+ pass
453
+
454
+ def conflict(*args, **kwargs):
455
+ raise DraftConflictError("remote draft has changed")
456
+
457
+ fake_api.update_draft_from_markdown = conflict
458
+ art = load_article(str(essay_dir / "essay.md"))
459
+ with pytest.raises(AdapterError, match="overwrite_edits"):
460
+ create_substack_draft(art, config=SubstackConfig(draft_id=1), api=fake_api)
@@ -8,6 +8,7 @@ one draft and deletes it again; it never publishes.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import json
11
12
  import os
12
13
 
13
14
  import pytest
@@ -29,6 +30,13 @@ pytestmark = pytest.mark.skipif(
29
30
  )
30
31
 
31
32
 
33
+ def _image_nodes(node):
34
+ found = [node] if node.get("type") == "image2" else []
35
+ for child in node.get("content") or []:
36
+ found += _image_nodes(child)
37
+ return found
38
+
39
+
32
40
  def test_a_real_draft_keeps_footnotes_and_captions(tmp_path, png_bytes):
33
41
  (tmp_path / "fig.png").write_bytes(png_bytes)
34
42
  (tmp_path / "smoke.md").write_text(
@@ -47,6 +55,9 @@ def test_a_real_draft_keeps_footnotes_and_captions(tmp_path, png_bytes):
47
55
  draft = api.get_draft(result.detail["draft_id"])
48
56
  assert not draft.get("is_published")
49
57
  body = str(draft.get("draft_body"))
50
- assert "footnote" in body and "A caption" in body and "substackcdn" in body
58
+ assert "footnote" in body and "A caption" in body
59
+ (image,) = _image_nodes(json.loads(draft["draft_body"]))
60
+ assert image["attrs"]["src"].startswith("https://")
61
+ assert (image["attrs"]["width"], image["attrs"]["height"]) == (1, 1)
51
62
  finally:
52
63
  api.delete_draft(result.detail["draft_id"])
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes