kausalflow/lightkone / components/library-file/versions/1/schema.py
· raw
"""Interface for lightkone/library-file.
The input is library.json's raw text, because what is being specified is how
lightkone reads and rewrites that file. Text that is not a lightkone library
at all is in scope (it must raise ValueError). A library whose papers lack
the fields lightkone always writes is out of scope: lightkone never produces
one, so Input rejects it.
"""
import json
import math
import re
from typing import Annotated, Any
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationError, field_validator
FieldName = Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_.:+-]*$")]
SURROGATE = re.compile(r"[\ud800-\udfff]")
class StoredPaper(BaseModel):
"""A paper as lightkone stores it. Other keys may appear and must be kept."""
model_config = ConfigDict(extra="allow", strict=True)
id: str
key: str
type: str
title: str
authors: list[str]
year: int | float | None = Field(None, description="A whole number; 2017.0 is read as 2017.")
venue: str | None = None
abstract: str | None = None
doi: str | None = None
arxiv: str | None = None
url: str | None = None
tags: list[str]
file: str | None = None
added: str
folders: list[str] | None = None
fields: dict[FieldName, str]
@field_validator("year")
@classmethod
def _whole(cls, year):
if isinstance(year, float) and not year.is_integer():
raise ValueError("year is a whole number")
return year
def _check_values(v: Any) -> None:
if v is None:
raise ValueError("lightkone never writes null inside a library")
if isinstance(v, bool):
return
if isinstance(v, int) and abs(v) > 2**53:
raise ValueError("integers beyond 2**53 are not exact in JavaScript")
if isinstance(v, float) and not math.isfinite(v):
raise ValueError("numbers too large for a double are out of scope")
if isinstance(v, str) and SURROGATE.search(v):
raise ValueError("lone surrogates are out of scope")
if isinstance(v, dict):
for k, x in v.items():
_check_values(k)
_check_values(x)
if isinstance(v, list):
for x in v:
_check_values(x)
def _not_json(name: str):
raise ValueError(f"{name} is not JSON")
class Input(BaseModel):
model_config = ConfigDict(extra="forbid")
text: str = Field(description="lightkone/library.json exactly as it is stored.")
@field_validator("text")
@classmethod
def _in_scope(cls, text: str) -> str:
try:
data = json.loads(text, parse_constant=_not_json)
except ValueError:
return text
if not (isinstance(data, dict) and data.get("format") == "lightkone-library"):
return text
papers = data.get("papers")
if papers == "":
raise ValueError('"papers": "" is out of scope (lightkone happens to accept it)')
if not isinstance(papers, list) or not all(isinstance(p, dict) for p in papers):
return text
_check_values(data)
for p in papers:
try:
StoredPaper.model_validate(p)
except ValidationError as e:
raise ValueError(f"paper {p.get('id')!r} is not shaped the way lightkone writes papers: {e}") from None
sync = data.get("bibSync")
if sync is not None and not (isinstance(sync, list) and all(isinstance(t, dict) for t in sync)):
raise ValueError("bibSync, when present, is a list of objects")
return text
class Output(BaseModel):
model_config = ConfigDict(extra="forbid")
written: bool = Field(description="Whether saving writes anything: false when library.json would not change.")
library: str = Field(description="The text of library.json after the save.")
bib: str | None = Field(description="The library.bib written with it; null when nothing is written.")
foldered: int = Field(ge=0, description="How many papers took their folders from Paperpile's file paths.")