Something went wrong. Try again.
A CLI to (readonly) query Scrivener project in textual form
scrivener cli
Something went wrong. Try again.
11 kB · 297 lines
Python
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298"""End-to-end tests for the ``scrivcli`` command line."""
from __future__ import annotations
import subprocessimport tempfileimport unittestfrom pathlib import Path
from helpers import item, make_bundle, run_cli, run_cli_bytes
UUID = "11111111-2222-3333-4444-555555555555"
def u(n: int) -> str: return f"00000000-0000-0000-0000-{n:012d}"
SAMPLE_BINDER = ( item(u(1), "Draft", type="DraftFolder", children=( item( u(2), "Chapter One", type="Folder", inner=( "<MetaData><IncludeInCompile>No</IncludeInCompile><LabelID>1</LabelID>" "<StatusID>2</StatusID></MetaData><Keywords><KeywordID>3</KeywordID></Keywords>" ), children=item(u(3), "Scene A") + item(u(4), ""), ) + item(u(5), "Scene B") )) + item(u(6), "Research", type="ResearchFolder", children=item(u(7), "Photo", type="Image")) + item(u(8), "Trash", type="TrashFolder", children=item(u(9), "Scene B")))
SAMPLE_DEFINITIONS = ( '<LabelSettings><Labels><Label ID="1">Red</Label></Labels></LabelSettings>' '<StatusSettings><StatusItems><Status ID="2">Done</Status></StatusItems></StatusSettings>' '<Keywords><Keyword ID="3"><Title>Night</Title></Keyword></Keywords>')
SAMPLE_FILES = { f"Files/Data/{u(3)}/content.rtf": r"{\rtf1\ansi Hello brave new world.}", f"Files/Data/{u(5)}/content.rtf": r"{\rtf1\ansi One two three.}",}
TREE = """\Test├── (draft) Draft│ ├── Chapter One [Red] [Done] [{Night}] [no-compile]│ │ ├── Scene A│ │ └── (Untitled)│ └── Scene B├── (research) Research│ └── Photo└── (trash) Trash └── Scene B"""
FLAT = """\DraftDraft/Chapter One [Red] [Done] [{Night}] [no-compile]Draft/Chapter One/Scene ADraft/Chapter One/(Untitled)Draft/Scene BResearchResearch/PhotoTrashTrash/Scene B"""
class TestCommands(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls._tmp = tempfile.TemporaryDirectory() cls.bundle = make_bundle( Path(cls._tmp.name), SAMPLE_BINDER, extra=SAMPLE_DEFINITIONS, files=SAMPLE_FILES )
@classmethod def tearDownClass(cls) -> None: cls._tmp.cleanup()
def ok(self, *args: str) -> str: result = run_cli(args[0], self.bundle, *args[1:]) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stderr, "") return result.stdout
def fails(self, *args: str) -> str: result = run_cli(args[0], self.bundle, *args[1:]) self.assertEqual(result.returncode, 1) self.assertEqual(result.stdout, "") self.assertNotIn("Traceback", result.stderr) return result.stderr
def test_ls_tree(self) -> None: self.assertEqual(self.ok("ls"), TREE)
def test_ls_tree_with_ids(self) -> None: lines = self.ok("ls", "--ids").splitlines() self.assertEqual(lines[0], "Test") self.assertEqual( lines[2], f"│ ├── Chapter One <id {u(2)}> [Red] [Done] [{{Night}}] [no-compile]", ) self.assertEqual(lines[4], f"│ │ └── (Untitled) <id {u(4)}>") self.assertEqual(lines[-1], f" └── Scene B <id {u(9)}>")
def test_ls_no_badges(self) -> None: out = self.ok("ls", "--no-badges") self.assertEqual(out, TREE.replace(" [Red] [Done] [{Night}] [no-compile]", ""))
def test_ls_flat(self) -> None: self.assertEqual(self.ok("ls", "--flat"), FLAT)
def test_ls_flat_ids(self) -> None: lines = self.ok("ls", "--flat", "--ids", "--no-badges").splitlines() self.assertEqual(lines[3], f"Draft/Chapter One/(Untitled) <id {u(4)}>")
def test_ls_flat_text_only(self) -> None: self.assertEqual(self.ok("ls", "--flat", "--text-only").splitlines(), [ "Draft/Chapter One/Scene A", "Draft/Chapter One/(Untitled)", "Draft/Scene B", "Trash/Scene B", ])
def test_show_by_title_path_and_id(self) -> None: for ref in ("Scene A", "Draft/Chapter One/Scene A", u(3), u(3).lower()): with self.subTest(ref=ref): self.assertEqual(self.ok("show", ref).strip(), "Hello brave new world.")
def test_show_empty_document(self) -> None: self.assertEqual(self.ok("show", "(Untitled)").strip(), "")
def test_show_non_text_items(self) -> None: self.assertIn("'Photo' is a Image, not a text document", self.fails("show", "Photo")) self.assertIn("is a Folder, not a text document", self.fails("show", "Chapter One"))
def test_show_ambiguous_title(self) -> None: err = self.fails("show", "Scene B") self.assertIn("Multiple documents match 'Scene B'", err) self.assertIn(f"Draft/Scene B <id {u(5)}>", err) self.assertIn(f"Trash/Scene B <id {u(9)}>", err) self.assertTrue(err.rstrip().endswith("Refine the reference (use a slash-path or ID)."))
def test_show_ambiguity_resolved_by_path(self) -> None: self.assertEqual(self.ok("show", "Draft/Scene B").strip(), "One two three.")
def test_show_unknown_reference(self) -> None: self.assertEqual(self.fails("show", "Nope"), "scrivcli: No document matching 'Nope'\n")
def test_meta_document(self) -> None: out = self.ok("meta", "Draft/Scene B") self.assertRegex(out, r"Title +Scene B\n") self.assertRegex(out, r"Type +Text\n") self.assertRegex(out, rf"ID +{u(5)}\n") self.assertRegex(out, r"Path +Draft/Scene B\n") self.assertRegex(out, r"Label +-\n") self.assertRegex(out, r"Words +3\n") self.assertRegex(out, r"Synopsis +-\n?$")
def test_meta_folder_with_badges(self) -> None: out = self.ok("meta", "Chapter One") self.assertRegex(out, r"Label +Red\n") self.assertRegex(out, r"Status +Done\n") self.assertRegex(out, r"Keywords +Night\n") self.assertRegex(out, r"Include in compile +no\n") self.assertNotIn("Words", out) self.assertNotIn("Synopsis", out)
def test_meta_ambiguous(self) -> None: self.assertIn("Multiple documents match", self.fails("meta", "Scene B"))
def test_info(self) -> None: lines = self.ok("info").splitlines() self.assertEqual(lines[0], f"Project {self.bundle}") self.assertIn("Version 2.0 (creator SCRMAC-3.5.2-17035)", lines) self.assertIn("Author -", lines) self.assertIn("Format mac", lines) self.assertIn("Documents 4", lines) self.assertIn("Words 7", lines)
def test_usage_errors_exit_2(self) -> None: for args in ((), ("ls",), ("bogus",)): with self.subTest(args=args): result = run_cli(*args) self.assertEqual(result.returncode, 2) self.assertEqual(result.stdout, "") self.assertIn("usage: scrivcli", result.stderr)
class TestMalformedProjects(unittest.TestCase): def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory() self.tmp = Path(self._tmp.name)
def tearDown(self) -> None: self._tmp.cleanup()
def assertCleanFailure( self, result: subprocess.CompletedProcess[str], *fragments: str ) -> None: self.assertEqual(result.returncode, 1) self.assertEqual(result.stdout, "") self.assertTrue(result.stderr.startswith("scrivcli: "), result.stderr) self.assertNotIn("Traceback", result.stderr) for fragment in fragments: self.assertIn(fragment, result.stderr)
def _bundle_with_scrivx(self, text: str) -> Path: bundle = self.tmp / "Broken.scriv" bundle.mkdir() (bundle / "Broken.scrivx").write_text(text, encoding="utf-8") return bundle
def test_malformed_xml(self) -> None: bundle = self._bundle_with_scrivx("<ScrivenerProject><Binder>") for command in ("info", "ls"): with self.subTest(command=command): result = run_cli(command, bundle) self.assertCleanFailure(result, "Malformed project file", "Broken.scrivx")
def test_empty_project_file(self) -> None: bundle = self._bundle_with_scrivx("") self.assertCleanFailure(run_cli("info", bundle), "Broken.scrivx")
def test_missing_binder(self) -> None: bundle = self._bundle_with_scrivx("<ScrivenerProject/>") self.assertCleanFailure(run_cli("ls", bundle), "No <Binder>", "Broken.scrivx")
def test_invalid_metadata_definition_ids(self) -> None: cases = { "label": '<LabelSettings><Labels><Label ID="red">Red</Label></Labels></LabelSettings>', "status": ( '<StatusSettings><StatusItems><Status ID="x">Draft</Status>' "</StatusItems></StatusSettings>" ), "keyword": ( '<Keywords><Keyword ID="0"><Title>A</Title><Children>' '<Keyword ID="bad"><Title>B</Title></Keyword></Children></Keyword></Keywords>' ), } for kind, extra in cases.items(): with self.subTest(kind=kind): bundle = make_bundle(self.tmp, item(UUID, "Doc"), extra=extra, name=kind) result = run_cli("meta", bundle, "Doc") self.assertCleanFailure( result, "Invalid metadata", f"{kind}.scrivx", f"{kind} definition" )
def test_missing_bundle(self) -> None: result = run_cli("info", self.tmp / "Nope.scriv") self.assertCleanFailure(result, "Not a Scrivener bundle", "Nope.scriv")
def test_bundle_without_project_file(self) -> None: (self.tmp / "Empty.scriv").mkdir() result = run_cli("ls", self.tmp / "Empty.scriv") self.assertCleanFailure(result, "No .scrivx file")
class TestShowRaw(unittest.TestCase): def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory() self.tmp = Path(self._tmp.name)
def tearDown(self) -> None: self._tmp.cleanup()
def assertRawRoundTrip(self, content: bytes, name: str) -> None: bundle = make_bundle( self.tmp, item(UUID, "Doc"), files={f"Files/Data/{UUID}/content.rtf": content}, name=name, ) result = run_cli_bytes("show", bundle, "Doc", "--raw") self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout, content)
def test_without_trailing_newline(self) -> None: self.assertRawRoundTrip(rb"{\rtf1\ansi Hello}", "no_newline")
def test_with_trailing_newline(self) -> None: self.assertRawRoundTrip(b"{\\rtf1\\ansi Hello}\n", "newline")
def test_crlf_and_non_utf8_bytes(self) -> None: self.assertRawRoundTrip(b"{\\rtf1\\ansi \\bin2 \xff\xfe}\r\n\r\n", "binary")
def test_missing_content_prints_nothing(self) -> None: bundle = make_bundle(self.tmp, item(UUID, "Doc"), name="empty") result = run_cli_bytes("show", bundle, "Doc", "--raw") self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout, b"")
if __name__ == "__main__": unittest.main()