From e137725cab43b552303618bfe8fc42ab10b6378f Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 2 Sep 2026 15:38:47 +0100 Subject: [PATCH] Add json/csv support to the posting script (issue #012) TEXT_KINDS and TEXT_SUFFIXES now include json/csv, with matching sniff_text_kind branches so both explicit --kind and positional auto-mode work. Added validate_payload's JSON shape check mirroring the server. Updated SKILL.md/docs/llm_posting_helper.md with real usage examples, replacing the stale --as-markdown workaround guidance (kept only for genuinely unstructured code, which still has no dedicated kind). Also fixed an unrelated stale doc line found in the same table: html_full's validation was documented as requiring --- docs/llm_posting_helper.md | 16 ++- ...posting-script-missing-json-csv-support.md | 26 +++- skills/daybook-posting/SKILL.md | 13 +- .../daybook-posting/scripts/daybook_post.py | 13 +- tests/scripts/__init__.py | 0 tests/scripts/test_daybook_post.py | 117 ++++++++++++++++++ 6 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 tests/scripts/__init__.py create mode 100644 tests/scripts/test_daybook_post.py diff --git a/docs/llm_posting_helper.md b/docs/llm_posting_helper.md index 0062790..9794324 100644 --- a/docs/llm_posting_helper.md +++ b/docs/llm_posting_helper.md @@ -56,10 +56,19 @@ python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title python skills/daybook-posting/scripts/daybook_post.py --file report.pdf --title "Generated Report" ``` -Render CSV/JSON/code as Markdown today: +Post CSV or JSON as an interactive table/tree (auto-mode detects the kind +from the extension; explicit `--kind` works too): ```bash -python skills/daybook-posting/scripts/daybook_post.py --file results.csv --as-markdown --title "Results CSV" +python skills/daybook-posting/scripts/daybook_post.py results.csv +python skills/daybook-posting/scripts/daybook_post.py --kind json --title "Run Summary" --content-file summary.json +``` + +Render other code as a Markdown fenced block instead (no dedicated `code` +kind yet): + +```bash +python skills/daybook-posting/scripts/daybook_post.py --file script.py --as-markdown --title "Generated Script" ``` Dry run: @@ -78,8 +87,9 @@ The helper validates common LLM mistakes before sending: |---|---| | `deck` | Must contain ` (and ideally )") if kind == "markdown" and not payload["content"].strip(): raise ValueError("markdown content cannot be blank") + if kind == "json": + try: + json.loads(payload["content"]) + except json.JSONDecodeError as exc: + raise ValueError(f"json content must be valid JSON: {exc}") from exc def print_dry_run(base_url, token, payload, args): diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scripts/test_daybook_post.py b/tests/scripts/test_daybook_post.py new file mode 100644 index 0000000..b6d0aa0 --- /dev/null +++ b/tests/scripts/test_daybook_post.py @@ -0,0 +1,117 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[2] + / "skills" + / "daybook-posting" + / "scripts" + / "daybook_post.py" +) + + +def _load_module(): + spec = importlib.util.spec_from_file_location("daybook_post", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +daybook_post = _load_module() + + +class SniffTextKindTestCase(unittest.TestCase): + def test_markdown_suffixes(self): + for suffix in (".md", ".markdown", ".txt"): + self.assertEqual(daybook_post.sniff_text_kind(suffix, "anything"), "markdown") + + def test_json_suffix(self): + self.assertEqual(daybook_post.sniff_text_kind(".json", '{"a": 1}'), "json") + + def test_csv_suffix(self): + self.assertEqual(daybook_post.sniff_text_kind(".csv", "a,b\n1,2"), "csv") + + def test_html_full_sniffed_from_content(self): + self.assertEqual( + daybook_post.sniff_text_kind(".html", ""), "html_full" + ) + + def test_deck_sniffed_from_content(self): + self.assertEqual(daybook_post.sniff_text_kind(".html", "
slide
"), "deck") + + def test_html_fragment_is_the_fallback(self): + self.assertEqual(daybook_post.sniff_text_kind(".html", "

hi

"), "html_fragment") + + +class ValidatePayloadTestCase(unittest.TestCase): + def test_valid_json_passes(self): + daybook_post.validate_payload({"kind": "json", "content": '{"a": 1}'}, None) # no raise + + def test_invalid_json_raises(self): + with self.assertRaises(ValueError): + daybook_post.validate_payload({"kind": "json", "content": "{not json"}, None) + + def test_deck_without_section_raises(self): + with self.assertRaises(ValueError): + daybook_post.validate_payload({"kind": "deck", "content": "

no slides

"}, None) + + def test_html_fragment_with_nested_html_raises(self): + with self.assertRaises(ValueError): + daybook_post.validate_payload( + {"kind": "html_fragment", "content": "x"}, None + ) + + def test_html_full_without_html_tag_raises(self): + with self.assertRaises(ValueError): + daybook_post.validate_payload({"kind": "html_full", "content": "

x

"}, None) + + def test_blank_markdown_raises(self): + with self.assertRaises(ValueError): + daybook_post.validate_payload({"kind": "markdown", "content": " "}, None) + + def test_csv_has_no_shape_check(self): + daybook_post.validate_payload({"kind": "csv", "content": "anything at all"}, None) # no raise + + def test_upload_path_skips_all_checks(self): + daybook_post.validate_payload({"upload_path": Path("whatever.png")}, None) # no raise + + +class ResolveAutoPathTestCase(unittest.TestCase): + def _args(self, path): + class Args: + pass + + args = Args() + args.path = str(path) + args.as_markdown = False + args.title = None + return args + + def test_json_file_infers_json_kind(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "data.json" + path.write_text('{"a": 1}', encoding="utf-8") + args = daybook_post.resolve_auto_path(self._args(path)) + self.assertEqual(args.kind, "json") + self.assertEqual(args.content_file, str(path)) + + def test_csv_file_infers_csv_kind(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "data.csv" + path.write_text("a,b\n1,2", encoding="utf-8") + args = daybook_post.resolve_auto_path(self._args(path)) + self.assertEqual(args.kind, "csv") + + def test_image_file_becomes_multipart_upload(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "chart.png" + path.write_bytes(b"\x89PNG\r\n") + args = daybook_post.resolve_auto_path(self._args(path)) + self.assertIsNone(args.kind) + self.assertEqual(args.file, str(path)) + + +if __name__ == "__main__": + unittest.main()