kausalflow/lightkone / components/library-file/versions/1/properties.py
· raw
"""Behavioral properties for lightkone/library-file.
Libraries are generated the way they arise: imported from Paperpile (Drive
paths, no folders), already saved by lightkone, or edited by other tools
(compact JSON, extra keys). Strings and numbers stay simple here; the
examples pin down escaping and number printing.
"""
import json
import re
from hypothesis import given, strategies as st
from schema import Input
SEGMENTS = ["All Papers", "HINTON", "DOE", "Other", "ML", "Physics", "QFT", "Reading group", "McKay", "2019"]
INDEX = re.compile(r"^(0|[1-9][0-9]*)$")
def clean(s):
"""lightkone/pdf-filing's clean()."""
s = s.replace("---", "\u2014").replace("--", "\u2013")
s = re.sub(r'[:/\\?*"<>|]', " - ", s)
s = re.sub(r"\s+", " ", s)
s = re.sub(r"( - )+", " - ", s)
return re.sub(r"^[\s-]+|[\s-]+$", "", s)
def author_folders(authors):
return {f for f in (clean(a.split(",")[0].strip()).upper() for a in authors) if f}
def folders_from(file, authors=()):
"""The folder rule of the description, written out independently."""
names = author_folders(authors)
def author_folder(d):
if d == "Other":
return True
return d in names if names else d == d.upper() and d != d.lower()
out = []
for path in (file or "").split(";"):
path = path.strip()
if not path:
continue
dirs = path.split("/")[:-1]
if dirs[:1] == ["All Papers"]:
dirs = dirs[1:]
if not dirs or (len(dirs) == 1 and author_folder(dirs[0])):
continue
f = "/".join(dirs)
if f not in out:
out.append(f)
return out
paths = st.lists(st.sampled_from(SEGMENTS), max_size=3).map(lambda d: "/".join(d + ["x.pdf"]))
@st.composite
def paper(draw, i):
p = {"id": f"p{i}", "key": f"Key{i}", "type": draw(st.sampled_from(["article", "book", "misc"])),
"title": draw(st.sampled_from(["Gauge fields", "Dropout", "Notes on QFT"])),
"authors": draw(st.lists(st.sampled_from(["Doe, Jane", "Roe, R.", "Hinton, G. E.", "Ml, Kim"]), max_size=2)),
"tags": draw(st.lists(st.sampled_from(["ml", "physics"]), unique=True, max_size=2)),
"added": "2024-01-01T00:00:00.000Z", "fields": {}}
if draw(st.booleans()):
p["year"] = draw(st.integers(1900, 2030))
if draw(st.booleans()):
p["file"] = ";".join(draw(st.lists(paths, min_size=1, max_size=3)))
if draw(st.booleans()):
p["folders"] = draw(st.lists(st.sampled_from(["ML", "Physics/QFT"]), unique=True, max_size=2))
if draw(st.booleans()):
p["starred"] = True
if draw(st.booleans()):
p["trashed"] = "2024-02-01T00:00:00.000Z"
return p
@st.composite
def library(draw):
data = {"format": "lightkone-library", "version": 1,
"papers": [draw(paper(i)) for i in range(draw(st.integers(0, 4)))]}
if draw(st.booleans()):
data["bibSync"] = draw(st.lists(st.just({"folder": "", "repo": "a/b", "path": "refs.bib"}), max_size=1))
if draw(st.booleans()):
data["note"] = "kept elsewhere"
if draw(st.booleans()):
data = {"source": {"tool": "sync", "at": 3}, **data}
return data
@st.composite
def stored(draw):
"""(data, text): a library and the text of the file holding it."""
data = draw(library())
indent = draw(st.sampled_from([None, 1, 2]))
return data, json.dumps(data, indent=indent, ensure_ascii=False)
def run(impl, text):
return impl(Input(text=text))
@given(stored())
def test_saved_file_keeps_exactly_the_library(impl, s):
data, text = s
out = json.loads(run(impl, text).library)
expect = {"format": "lightkone-library", "version": 1, "papers": data["papers"]}
if data.get("bibSync"):
expect["bibSync"] = data["bibSync"]
expect.update((k, v) for k, v in data.items() if k not in ("format", "version", "papers", "bibSync"))
for p in expect["papers"]:
p.setdefault("folders", folders_from(p.get("file"), p["authors"]))
assert out == expect
assert list(out) == list(expect), "other tools' keys come after lightkone's, in the file's order"
@given(stored())
def test_papers_gain_folders_from_paperpile_paths(impl, s):
data, text = s
out = run(impl, text)
papers = json.loads(out.library)["papers"]
for before, after in zip(data["papers"], papers):
assert after["folders"] == before.get("folders", folders_from(before.get("file"), before["authors"]))
assert list(after)[: len(before)] == list(before), "existing keys keep their order; folders goes last"
assert out.foldered == sum(1 for p in data["papers"] if "folders" not in p and folders_from(p.get("file"), p["authors"]))
@given(stored())
def test_layout_is_one_space_indented_json(impl, s):
data, text = s
out = run(impl, text)
assert out.library == json.dumps(json.loads(out.library), indent=1, ensure_ascii=False)
@given(stored())
def test_writes_only_when_the_text_changes(impl, s):
data, text = s
out = run(impl, text)
assert out.written == (out.library != text)
assert (out.bib is None) == (not out.written)
if not out.written:
assert out.library == text
@given(stored())
def test_saving_again_writes_nothing(impl, s):
data, text = s
first = run(impl, text)
again = run(impl, first.library)
assert not again.written and again.library == first.library and again.foldered == 0
@given(library(), st.booleans())
def test_bib_has_one_entry_per_live_paper_in_order(impl, data, pretty):
text = json.dumps(data) + ("\n" if pretty else "")
out = run(impl, text)
assert out.written
heads = re.findall(r"^@(\w+)\{([^,\n]*),$", out.bib, re.M)
live = [p for p in data["papers"] if not p.get("trashed")]
assert heads == [(p["type"] or "misc", p["key"]) for p in live]
assert (out.bib == "") == (not live)
not_a_library = st.one_of(
st.sampled_from(["", "nope", "{", "[]", "null", "true", "3", '"x"', "{}", "NaN"]),
st.builds(lambda f: json.dumps({"format": f, "version": 1, "papers": []}),
st.sampled_from(["", "lightkone", "Lightkone-library", "lightkone-library ", "paperpile"])),
st.builds(lambda p: json.dumps({"format": "lightkone-library", "version": 1, **p}),
st.sampled_from([{}, {"papers": {}}, {"papers": 3}, {"papers": "abc"}, {"papers": [None]}, {"papers": [1, {}]},
{"version": 2, "papers": []}, {"version": 1.5, "papers": []}, {"version": 1000, "papers": []}])),
)
@given(not_a_library)
def test_anything_else_is_refused(impl, text):
try:
out = run(impl, text)
except ValueError:
return
raise AssertionError(f"accepted a file that is not a lightkone library: {out}")