kausalflow/lightkone / components/bibtex-import/versions/1/properties.py
· raw
"""Behavioral properties for lightkone/bibtex-import.
Entries are generated as data, rendered as BibTeX in the many ways exporters
write it (braces or quotes, any field-name case, odd spacing, parentheses,
trailing commas), and the drafts must come back as the description says.
Generated values avoid LaTeX, so plain() leaves them alone; LaTeX and the
messier corners are pinned by the examples.
"""
from hypothesis import given, strategies as st
from schema import Draft, Input
WORD = st.text("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", min_size=1, max_size=8)
TEXT = st.lists(WORD, min_size=1, max_size=5).map(" ".join)
KEY = st.text("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:_-", min_size=1, max_size=12)
GAP = st.sampled_from(["", " ", " ", "\n", "\n ", "\t"])
NAME = st.text("abcdefghijklmnopqrstuvwxyz", min_size=2, max_size=8).map(str.capitalize).filter(lambda w: w.lower() not in ("and", "others"))
def run(impl, text: str) -> list[Draft]:
return impl(Input(text=text)).papers
def collapse(s: str) -> str:
return " ".join(s.split())
@st.composite
def value(draw, v: str) -> str:
"""One way an exporter writes the value v."""
if v.isdigit() and draw(st.booleans()):
return v
if draw(st.booleans()):
return "{" + v + "}"
return '"' + v + '"'
@st.composite
def entry_text(draw, type_: str, key: str, fields: list[tuple[str, str]]) -> str:
"""Render one entry. Field names get a random case; values a random style."""
paren = draw(st.booleans())
open_, close = ("(", ")") if paren else ("{", "}")
parts = []
for name, v in fields:
name = "".join(c.upper() if draw(st.booleans()) else c for c in name)
parts.append(f"{draw(GAP)}{name}{draw(GAP)}={draw(GAP)}{draw(value(v))}")
body = ",".join(parts) + ("," if draw(st.booleans()) else "") + draw(GAP)
return f"@{type_}{draw(GAP)}{open_}{draw(GAP)}{key},{body}{close}"
@st.composite
def simple_entry(draw):
"""(BibTeX text, expected draft) for an entry with ordinary fields."""
type_ = draw(st.sampled_from(["article", "ARTICLE", "InProceedings", "misc", "book"]))
key = draw(KEY)
title = draw(TEXT)
names = draw(st.lists(st.tuples(st.lists(NAME, min_size=0, max_size=2), NAME), max_size=4))
comma_style = draw(st.booleans())
rendered = [f"{last}, {' '.join(first)}" if comma_style and first else " ".join([*first, last]) for first, last in names]
authors = [f"{last}, {' '.join(first)}" if first else last for first, last in names]
year = draw(st.none() | st.integers(1000, 9999))
venue_field = draw(st.sampled_from(["journal", "booktitle", None]))
venue = draw(TEXT) if venue_field else None
extra = draw(st.dictionaries(st.sampled_from(["pages", "volume", "number", "publisher", "isbn", "series"]), TEXT, max_size=3))
fields = [("title", title)]
if rendered:
fields.append(("author", draw(st.sampled_from([" and ", " AND ", "\nand "])).join(rendered)))
if year is not None:
fields.append(("year", str(year)))
if venue_field:
fields.append((venue_field, venue))
fields += list(extra.items())
fields = draw(st.permutations(fields))
if venue is None and "publisher" in extra:
venue = extra["publisher"]
expected = Draft(
key=key, type=type_.lower(), title=title, authors=authors, year=year, venue=venue,
tags=[], fields={k: collapse(v) for k, v in extra.items()},
)
return draw(entry_text(type_, key, fields)), expected
@given(entries=st.lists(simple_entry(), max_size=4), junk=st.lists(st.sampled_from(["", "\n\n", "% a comment\n", "text between entries\n"]), min_size=5, max_size=5))
def test_well_formed_entries_become_drafts(impl, entries, junk):
text = junk[0] + "".join(t + junk[i % 4 + 1] for i, (t, _) in enumerate(entries))
assert run(impl, text) == [d for _, d in entries]
@given(entries=st.lists(simple_entry(), min_size=1, max_size=3), drop=st.data())
def test_entries_without_a_title_are_left_out(impl, entries, drop):
keep = drop.draw(st.lists(st.booleans(), min_size=len(entries), max_size=len(entries)))
text = ""
for (t, d), k in zip(entries, keep):
text += (t if k else f"@misc{{{d.key}, note = {{no title here}}}}") + "\n"
assert [p.key for p in run(impl, text)] == [d.key for (_, d), k in zip(entries, keep) if k]
@given(e=simple_entry(), wrapper=st.sampled_from(["@comment{{{0}}}", "@preamble{{\"{0}\"}}", "@COMMENT {{{0}}}"]), note=TEXT)
def test_comments_and_preambles_are_not_entries(impl, e, wrapper, note):
text, draft = e
assert run(impl, wrapper.format(note) + "\n" + text) == [draft]
@given(name=st.text("abcdefghij", min_size=3, max_size=6), v=TEXT, suffix=TEXT, key=KEY, upper=st.booleans())
def test_string_macros_and_concatenation(impl, name, v, suffix, key, upper):
use = name.upper() if upper else name
text = f'@string{{{name} = "{v}"}}\n@article{{{key}, title = {{T}}, journal = {use} # " {suffix}"}}'
assert run(impl, text)[0].venue == f"{v} {suffix}"
@given(month=st.sampled_from(["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]), key=KEY, upper=st.booleans())
def test_month_macros(impl, month, key, upper):
full = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October",
"November", "December"][["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"].index(month)]
papers = run(impl, f"@misc{{{key}, title = {{T}}, month = {month.upper() if upper else month}}}")
assert papers[0].fields["month"] == full
@given(key=KEY, pieces=st.lists(st.tuples(WORD, GAP), min_size=1, max_size=6))
def test_whitespace_in_values_collapses(impl, key, pieces):
raw = "".join(w + (g or " ") for w, g in pieces)
papers = run(impl, f"@misc{{{key}, title = {{{raw}}}, howpublished = {{ {raw} }}}}")
assert papers[0].title == collapse(raw) and papers[0].fields["howpublished"] == collapse(raw)
@given(key=KEY, a=TEXT, b=TEXT)
def test_later_fields_replace_earlier_ones(impl, key, a, b):
papers = run(impl, f"@misc{{{key}, title = {{{a}}}, title = {{{b}}}, pages = {{{a}}}, PAGES = {{{b}}}}}")
assert papers[0].title == b and papers[0].fields == {"pages": b}
@given(key=KEY, corp=TEXT.filter(lambda t: "and" not in t.lower().split()), name=NAME)
def test_braced_names_and_others_are_kept(impl, key, corp, name):
papers = run(impl, f"@misc{{{key}, title = {{T}}, author = {{{{{corp}}} and Ada {name} and others}}}}")
assert papers[0].authors == [corp, f"{name}, Ada", "others"]
@given(key=KEY, ed=st.lists(NAME, min_size=1, max_size=3))
def test_editors_stand_in_for_missing_authors(impl, key, ed):
papers = run(impl, f"@book{{{key}, title = {{T}}, editor = {{{' and '.join(ed)}}}}}")
assert papers[0].authors == ed and "editor" not in papers[0].fields
@given(key=KEY, doi=st.from_regex(r"10\.[0-9]{4,5}/[a-z0-9.]{1,10}", fullmatch=True),
prefix=st.sampled_from(["", "https://doi.org/", "http://dx.doi.org/", "HTTPS://DOI.ORG/"]))
def test_doi_loses_its_resolver_prefix(impl, key, doi, prefix):
assert run(impl, f"@article{{{key}, title = {{T}}, doi = {{{prefix}{doi}}}}}")[0].doi == doi
ARXIV = st.from_regex(r"[0-9]{4}\.[0-9]{4,5}", fullmatch=True)
@given(key=KEY, aid=ARXIV, version=st.sampled_from(["", "v1", "v12"]), how=st.sampled_from(["eprint", "arxivid", "url-abs", "url-pdf", "bare"]))
def test_arxiv_ids_are_found(impl, key, aid, version, how):
fields = {
"eprint": f"eprint = {{arXiv:{aid}{version}}}, archivePrefix = {{arXiv}}, primaryClass = {{cs.LG}}",
"arxivid": f"arxivid = {{{aid}{version}}}",
"url-abs": f"url = {{https://arxiv.org/abs/{aid}{version}}}",
"url-pdf": f"url = {{https://arxiv.org/pdf/{aid}{version}.pdf}}",
"bare": f"eprint = {{{aid}{version}}}",
}[how]
p = run(impl, f"@article{{{key}, title = {{T}}, {fields}}}")[0]
assert p.arxiv == aid
assert not {"eprint", "archiveprefix", "primaryclass", "arxivid"} & set(p.fields)
@given(key=KEY, eprint=st.from_regex(r"[a-z]{3,6}/[0-9]{3}", fullmatch=True))
def test_other_eprints_stay_in_fields(impl, key, eprint):
p = run(impl, f"@article{{{key}, title = {{T}}, eprint = {{{eprint}}}}}")[0]
assert p.arxiv is None and p.fields == {"eprint": eprint}
@given(key=KEY, tags=st.lists(TEXT, min_size=1, max_size=4), seps=st.sampled_from([";", ",", "; ", " , "]),
ids=st.lists(st.sampled_from(["doi:10.1/x", "URL:http://a.b", "https://x.org", "arXiv:2101.00001", "isbn:123"]), max_size=2))
def test_keywords_become_tags_without_identifiers(impl, key, tags, seps, ids):
p = run(impl, f"@article{{{key}, title = {{T}}, keywords = {{{seps.join(tags + ids)}}}}}")[0]
assert p.tags == tags and "keywords" not in p.fields