diff --git a/CHANGELOG.md b/CHANGELOG.md index 79383e1..51f310e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index cd5da55..ac311fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/src/devpub/__init__.py b/src/devpub/__init__.py index a87bbdb..4207a1f 100644 --- a/src/devpub/__init__.py +++ b/src/devpub/__init__.py @@ -1,3 +1,3 @@ """devpub - Write in your editor. Publish to Dev.to. Track what works.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/src/devpub/cli/__init__.py b/src/devpub/cli/__init__.py index ab9db9e..cca5106 100644 --- a/src/devpub/cli/__init__.py +++ b/src/devpub/cli/__init__.py @@ -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 @@ -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() diff --git a/src/devpub/cli/images.py b/src/devpub/cli/images.py index f336188..f15b3f3 100644 --- a/src/devpub/cli/images.py +++ b/src/devpub/cli/images.py @@ -1,5 +1,7 @@ """Image upload commands for devpub.""" +import json +import sys from pathlib import Path from rich.console import Console @@ -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() @@ -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() @@ -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.[/]") diff --git a/src/devpub/core/article.py b/src/devpub/core/article.py index 38845c7..4ed46cc 100644 --- a/src/devpub/core/article.py +++ b/src/devpub/core/article.py @@ -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"): diff --git a/tests/test_articles.py b/tests/test_articles.py index df6e083..db49b66 100644 --- a/tests/test_articles.py +++ b/tests/test_articles.py @@ -117,7 +117,7 @@ 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" @@ -125,6 +125,34 @@ def test_full_article(self): 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) diff --git a/tests/test_cli_upload.py b/tests/test_cli_upload.py new file mode 100644 index 0000000..d09c8e2 --- /dev/null +++ b/tests/test_cli_upload.py @@ -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