forked from ChelseaKR/queer-the-stacks
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlists_store.py
More file actions
132 lines (105 loc) · 4.83 KB
/
Copy pathlists_store.py
File metadata and controls
132 lines (105 loc) · 4.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
"""Persistence for authored curated lists — local-only, no network.
:mod:`recommender.lists` defines the format (``CuratedList``, ``load_lists``,
``validate_lists``) but has no serializer or file store; this module adds
both, so a reader can *author* cited lists (not just consume the built-in
``DEMO_LISTS``) and persist them to ``data_dir / "lists.json"``.
Posture mirrors :mod:`app.share`: pure, local, no network access anywhere in
this module. Nothing here sends a list anywhere — ``export_lists`` only
returns a string; the CLI decides whether that goes to stdout or a local file
(manual-only, matching the share-card guardrail).
Every write path calls :func:`~recommender.lists.validate_lists` first, so an
invalid list (missing citation, no books) can never reach disk.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING
from recommender.lists import CuratedList, ListValidationError, load_lists, validate_lists
if TYPE_CHECKING:
from ingest.config import Config
def list_to_record(lst: CuratedList) -> dict[str, object]:
"""A :class:`CuratedList` as a plain, JSON-safe record."""
return {
"name": lst.name,
"citation": lst.citation,
"book_ids": list(lst.book_ids),
"retrieved_at": lst.retrieved_at,
}
def records_from_lists(lists: tuple[CuratedList, ...]) -> list[dict[str, object]]:
"""The inverse of :func:`~recommender.lists.load_lists`."""
return [list_to_record(lst) for lst in lists]
def list_store_path(config: Config) -> Path:
"""Where authored lists persist: ``<data_dir>/lists.json``."""
return config.data_dir / "lists.json"
def load_stored_lists(path: Path) -> tuple[CuratedList, ...]:
"""Load + validate authored lists from ``path``. Missing file → no lists."""
path = Path(path)
if not path.is_file():
return ()
raw = json.loads(path.read_text(encoding="utf-8"))
records = raw if isinstance(raw, list) else []
return load_lists(records)
def save_lists(path: Path, lists: tuple[CuratedList, ...]) -> None:
"""Validate, then persist ``lists`` as sorted-key JSON with a trailing newline.
Validation runs before any byte is written, so a partially-authored or
invalid list is never the thing that ends up on disk.
"""
validate_lists(lists)
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
body = json.dumps(records_from_lists(lists), indent=2, sort_keys=True)
path.write_text(body + "\n", encoding="utf-8")
def export_lists(lists: tuple[CuratedList, ...]) -> str:
"""The validated JSON string used by ``stacks lists export``."""
validate_lists(lists)
return json.dumps(records_from_lists(lists), indent=2, sort_keys=True) + "\n"
def new_list(
lists: tuple[CuratedList, ...],
name: str,
citation: str,
book_ids: tuple[str, ...] = (),
*,
retrieved_at: str,
) -> tuple[CuratedList, ...]:
"""Add a brand-new list named ``name``. Raises on a duplicate name.
``retrieved_at`` is required and keyword-only. This module stays pure — no
clock, matching :func:`ingest.refresh._retrieval_date`'s discipline — so the
caller supplies the date; ``stacks lists new`` passes today's UTC date, or
the ``--retrieved-at`` the reader gave for a list they are transcribing from
somewhere with a known fetch date. It used to be left to
:class:`~recommender.lists.CuratedList`'s default, which stamped every
authored list ``2026-06-05`` regardless of when it was made.
Immutable: returns a new tuple, does not mutate ``lists``.
"""
name = name.strip()
if not name:
raise ListValidationError("a curated list must have a name")
if any(lst.name == name for lst in lists):
raise ListValidationError(f"a list named {name!r} already exists")
candidate = CuratedList(
name=name, citation=citation, book_ids=tuple(book_ids), retrieved_at=retrieved_at
)
validate_lists((candidate,))
return (*lists, candidate)
def add_book_to_list(
lists: tuple[CuratedList, ...], name: str, book_id: str
) -> tuple[CuratedList, ...]:
"""Append ``book_id`` to the named list. Raises if the list does not exist.
Immutable: returns a new tuple with the one list replaced; adding a book
id already on the list is a no-op (idempotent).
"""
if not any(lst.name == name for lst in lists):
raise ListValidationError(f"no list named {name!r} — create it with 'lists new' first")
out = []
for lst in lists:
if lst.name == name and book_id not in lst.book_ids:
lst = CuratedList(
name=lst.name,
citation=lst.citation,
book_ids=(*lst.book_ids, book_id),
retrieved_at=lst.retrieved_at,
)
out.append(lst)
result = tuple(out)
validate_lists(result)
return result