Skip to content
Open
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.0] - 2026-08-28

### Added

- `devpub upload --json` prints a machine-readable result object (`uploaded` /
`failed` arrays) to stdout for scripting; human output goes to stderr and the
command exits non-zero if any file failed. Mutually exclusive with `--markdown`.

### Fixed

- `devpub push` now sends `tags` as an array. It was joining them into a
comma-separated string, which Dev.to accepts but silently drops, publishing
articles with no tags.

## [0.3.0] - 2026-08-27

### Added
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "devpub"
version = "0.3.0"
version = "0.4.0"
description = "Write in your editor. Publish to Dev.to. Track what works."
readme = "README.md"
license = {text = "MIT"}
Expand Down
2 changes: 1 addition & 1 deletion src/devpub/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""devpub - Write in your editor. Publish to Dev.to. Track what works."""

__version__ = "0.3.0"
__version__ = "0.4.0"
10 changes: 8 additions & 2 deletions src/devpub/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ def pull(pull_all, folder):
@click.option(
"--markdown", "-m", is_flag=True, help="Print Markdown image tags instead of a table."
)
def upload(files, markdown):
@click.option(
"--json", "as_json", is_flag=True, help="Print a JSON result object (for scripting)."
)
def upload(files, markdown, as_json):
"""Upload images to Dev.to and get their URLs.

\b
Expand All @@ -76,9 +79,12 @@ def upload(files, markdown):
DEVPUB_SESSION_COOKIE and DEVPUB_CSRF_TOKEN rather than your API key --
run it once without them for instructions on where to find them.
"""
if markdown and as_json:
raise click.UsageError("--markdown and --json are mutually exclusive.")

from devpub.cli.images import upload_images

upload_images(files=files, markdown=markdown)
upload_images(files=files, markdown=markdown, as_json=as_json)


@cli.command()
Expand Down
38 changes: 30 additions & 8 deletions src/devpub/cli/images.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Image upload commands for devpub."""

import json
import sys
from pathlib import Path

from rich.console import Console
Expand All @@ -10,18 +12,23 @@
from devpub.core.config import ensure_session_credentials


def upload_images(files: tuple, markdown: bool = False):
def upload_images(files: tuple, markdown: bool = False, as_json: bool = False):
"""Upload image files to Dev.to and print their public URLs.

Args:
files: Paths to upload, in the order given.
markdown: Print ready-to-paste Markdown instead of a table.
as_json: Print a machine-readable JSON result to stdout (for scripting).

Exits non-zero if any file failed to upload.
"""
console = Console()
# In --json mode, keep stdout pure JSON; send all human output to stderr.
log = Console(stderr=True) if as_json else console

if not files:
console.print("[yellow]No files given.[/]")
console.print(" Example: [cyan]devpub upload cover.png diagram.png[/]")
log.print("[yellow]No files given.[/]")
log.print(" Example: [cyan]devpub upload cover.png diagram.png[/]")
return

session_cookie, csrf_token = ensure_session_credentials()
Expand All @@ -36,11 +43,28 @@ def upload_images(files: tuple, markdown: bool = False):
url = uploader.upload(path)
except APIError as e:
failed.append((path, e.message))
console.print(f" [red]Failed:[/] {path.name} -- {e.message}")
log.print(f" [red]Failed:[/] {path.name} -- {e.message}")
continue
uploaded.append((path, url))
console.print(f" [green]Uploaded:[/] {path.name}")
log.print(f" [green]Uploaded:[/] {path.name}")

if as_json:
print(json.dumps(_json_result(uploaded, failed), indent=2))
else:
_print_human(console, uploaded, failed, markdown=markdown)

if failed:
sys.exit(1)


def _json_result(uploaded, failed) -> dict:
return {
"uploaded": [{"file": p.name, "path": str(p), "url": url} for p, url in uploaded],
"failed": [{"file": p.name, "path": str(p), "error": err} for p, err in failed],
}


def _print_human(console: Console, uploaded, failed, markdown: bool):
if uploaded:
if markdown:
console.print()
Expand All @@ -56,6 +80,4 @@ def upload_images(files: tuple, markdown: bool = False):
console.print()
console.print(table)

console.print(
f"\n[dim]Done. {len(uploaded)} uploaded, {len(failed)} failed.[/]"
)
console.print(f"\n[dim]Done. {len(uploaded)} uploaded, {len(failed)} failed.[/]")
8 changes: 5 additions & 3 deletions src/devpub/core/article.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,11 @@ def article_to_api_payload(article: dict) -> dict:
"published": article["published"],
}

if article.get("tags"):
tags = article["tags"]
payload["tags"] = tags if isinstance(tags, str) else ", ".join(tags)
tags = _parse_tags(article.get("tags"))
if tags:
# Dev.to expects an array of tags; a comma-joined string is accepted
# by the API but silently produces an empty tag_list on the article.
payload["tags"] = tags
if article.get("description"):
payload["description"] = article["description"]
if article.get("series"):
Expand Down
30 changes: 29 additions & 1 deletion tests/test_articles.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,42 @@ def test_full_article(self):
payload = article_to_api_payload(article)
assert payload["title"] == "Full Post"
assert payload["published"] is True
assert payload["tags"] == "python, tutorial"
assert payload["tags"] == ["python", "tutorial"]
assert payload["description"] == "A full article"
assert payload["series"] == "My Series"
assert payload["canonical_url"] == "https://myblog.com/post"
assert payload["main_image"] == "https://img.com/cover.png"
assert payload["organization_id"] == 42


class TestPayloadTags:
"""Dev.to expects `tags` as an array; a comma-joined string is silently
dropped, leaving the published article with no tags."""

def test_list_is_sent_as_array(self):
payload = article_to_api_payload(
{"title": "T", "body": "B", "published": False, "tags": ["a", "b"]}
)
assert payload["tags"] == ["a", "b"]

def test_comma_string_is_split_into_array(self):
payload = article_to_api_payload(
{"title": "T", "body": "B", "published": False, "tags": "python, tutorial"}
)
assert payload["tags"] == ["python", "tutorial"]

def test_empty_tags_are_omitted(self):
for empty in ([], "", " ", None):
payload = article_to_api_payload(
{"title": "T", "body": "B", "published": False, "tags": empty}
)
assert "tags" not in payload

def test_no_tags_key_is_omitted(self):
payload = article_to_api_payload({"title": "T", "body": "B", "published": False})
assert "tags" not in payload


class TestCreateArticle:
def test_create_default(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
Expand Down
80 changes: 80 additions & 0 deletions tests/test_cli_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""CLI-level tests for `devpub upload`, focused on output modes."""

import json

import pytest
from click.testing import CliRunner

from devpub.cli import cli


@pytest.fixture(autouse=True)
def _fake_session(monkeypatch):
"""Bypass the real credential lookup so the command can run offline.

Patched on the images module, where the name is actually resolved.
"""
import devpub.cli.images as images_mod

monkeypatch.setattr(
images_mod, "ensure_session_credentials", lambda: ("session", "csrf")
)


def _stub_uploads(monkeypatch, results):
"""Make ImageUploader.upload return a URL per filename, or raise for errors.

`results` maps a filename to either a URL string or an Exception to raise.
"""
from pathlib import Path

def fake_upload(self, path):
outcome = results[Path(path).name]
if isinstance(outcome, Exception):
raise outcome
return outcome

monkeypatch.setattr("devpub.api.uploads.ImageUploader.upload", fake_upload)


def test_json_output_is_pure_json_on_stdout(monkeypatch):
_stub_uploads(monkeypatch, {"a.png": "https://img/a.png", "b.png": "https://img/b.png"})
runner = CliRunner()
result = runner.invoke(cli, ["upload", "a.png", "b.png", "--json"])

assert result.exit_code == 0
payload = json.loads(result.stdout) # must parse cleanly -> no rich noise
assert [u["file"] for u in payload["uploaded"]] == ["a.png", "b.png"]
assert payload["uploaded"][0]["url"] == "https://img/a.png"
assert payload["failed"] == []


def test_json_reports_failures_and_exits_nonzero(monkeypatch):
from devpub.api.devto import APIError

_stub_uploads(
monkeypatch,
{"ok.png": "https://img/ok.png", "bad.png": APIError(422, "nope")},
)
runner = CliRunner()
result = runner.invoke(cli, ["upload", "ok.png", "bad.png", "--json"])

assert result.exit_code == 1
payload = json.loads(result.stdout)
assert [u["file"] for u in payload["uploaded"]] == ["ok.png"]
assert payload["failed"][0]["file"] == "bad.png"
assert payload["failed"][0]["error"] == "nope"


def test_markdown_and_json_are_mutually_exclusive():
runner = CliRunner()
result = runner.invoke(cli, ["upload", "a.png", "--markdown", "--json"])
assert result.exit_code != 0
assert "mutually exclusive" in result.output


def test_all_success_exits_zero(monkeypatch):
_stub_uploads(monkeypatch, {"a.png": "https://img/a.png"})
runner = CliRunner()
result = runner.invoke(cli, ["upload", "a.png", "--json"])
assert result.exit_code == 0
Loading