hyprmuse 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hyprmuse/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """hyprmuse - random quotes from musicians, authors and poets for hyprlock."""
hyprmuse/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Run the hyprmuse command: `python -m hyprmuse`, and the release binary's entry point.
2
+
3
+ The import is absolute because packaging/build-binary.sh hands this file to PyInstaller
4
+ as a plain script, outside the package, where a relative import has no parent.
5
+ """
6
+
7
+ from hyprmuse.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ raise SystemExit(main())
hyprmuse/cli.py ADDED
@@ -0,0 +1,232 @@
1
+ """Command line interface for hyprmuse."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from . import config, library, lockscreen
7
+ from .sources import AUTO_ORDER, REGISTRY, SourceError, get_source
8
+ from .sources.base import Candidate
9
+
10
+
11
+ def _err(msg: str) -> int:
12
+ print(f"hyprmuse: {msg}", file=sys.stderr)
13
+ return 1
14
+
15
+
16
+ def _progress(msg: str) -> None:
17
+ print(f" {msg}", file=sys.stderr)
18
+
19
+
20
+ def _gather(query: str, source: str, lang: str) -> list[Candidate]:
21
+ """Candidates from one named source, or from every source in auto order."""
22
+ names = [source] if source else AUTO_ORDER
23
+ found: list[Candidate] = []
24
+ for name in names:
25
+ try:
26
+ hits = get_source(name).search(query, lang)
27
+ except SourceError as exc:
28
+ if source:
29
+ raise
30
+ print(f" ({name} unavailable: {exc})", file=sys.stderr)
31
+ continue
32
+ found.extend(hits)
33
+ if source:
34
+ break
35
+ return found
36
+
37
+
38
+ def _choose(cands: list[Candidate], assume_yes: bool) -> Candidate | None:
39
+ if not cands:
40
+ return None
41
+ if assume_yes or len(cands) == 1 or not sys.stdin.isatty():
42
+ return cands[0]
43
+ print("\nMatches:", file=sys.stderr)
44
+ for i, c in enumerate(cands, 1):
45
+ hint = f" ({c.hint})" if c.hint else ""
46
+ print(f" {i:2}. [{c.source}/{c.domain}] {c.name}{hint}", file=sys.stderr)
47
+ try:
48
+ raw = input("\nPick a number (blank to cancel): ").strip()
49
+ except (EOFError, KeyboardInterrupt):
50
+ return None
51
+ if not raw.isdigit() or not 1 <= int(raw) <= len(cands):
52
+ return None
53
+ return cands[int(raw) - 1]
54
+
55
+
56
+ def cmd_search(args) -> int:
57
+ cands = _gather(args.query, args.source, args.lang)
58
+ if not cands:
59
+ return _err(f"no matches for {args.query!r}")
60
+ for c in cands:
61
+ hint = f" ({c.hint})" if c.hint else ""
62
+ print(f"[{c.source}/{c.domain}] {c.name}{hint}\n {c.source}:{c.source_id}")
63
+ return 0
64
+
65
+
66
+ def cmd_add(args) -> int:
67
+ try:
68
+ cands = _gather(args.query, args.source, args.lang)
69
+ except SourceError as exc:
70
+ return _err(str(exc))
71
+ cand = _choose(cands, args.yes)
72
+ if cand is None:
73
+ return _err(f"no match chosen for {args.query!r}")
74
+ print(f"Harvesting {cand.name} from {cand.source}…", file=sys.stderr)
75
+ try:
76
+ subject = get_source(cand.source).fetch(cand.source_id, _progress)
77
+ except SourceError as exc:
78
+ return _err(str(exc))
79
+ path = library.save(subject)
80
+ print(f"Saved {len(subject.items)} items for {subject.name} -> {path}")
81
+ return 0
82
+
83
+
84
+ def cmd_list(args) -> int:
85
+ subjects = library.load_all()
86
+ if not subjects:
87
+ print("No subjects yet. Try: hyprmuse add \"Franz Kafka\"")
88
+ return 0
89
+ width = max(len(s.name) for s in subjects)
90
+ total = 0
91
+ for s in subjects:
92
+ total += len(s.items)
93
+ kind = "atomic" if s.items and s.items[0].atomic else "sliceable"
94
+ print(f"{s.name:{width}} {len(s.items):5} items {s.domain:11} "
95
+ f"{s.lang:3} {kind:10} {library.subject_key(s.source, s.source_id)}")
96
+ print(f"\n{len(subjects)} subjects, {total} items total")
97
+ return 0
98
+
99
+
100
+ def cmd_remove(args) -> int:
101
+ matches = library.find(args.token)
102
+ if not matches:
103
+ return _err(f"no subject matching {args.token!r}")
104
+ if len(matches) > 1:
105
+ names = ", ".join(library.subject_key(m.source, m.source_id) for m in matches)
106
+ return _err(f"{args.token!r} is ambiguous: {names}")
107
+ subject = matches[0]
108
+ library.remove(subject)
109
+ print(f"Removed {subject.name}")
110
+ return 0
111
+
112
+
113
+ def cmd_update(args) -> int:
114
+ targets = library.load_all() if args.all else library.find(args.token or "")
115
+ if not targets:
116
+ return _err("nothing to update")
117
+ failed = 0
118
+ for subj in targets:
119
+ print(f"Updating {subj.name} ({subj.source})…", file=sys.stderr)
120
+ try:
121
+ fresh = get_source(subj.source).fetch(subj.source_id, _progress)
122
+ except SourceError as exc:
123
+ print(f" failed: {exc}", file=sys.stderr)
124
+ failed += 1
125
+ continue
126
+ library.save(fresh)
127
+ print(f" {len(fresh.items)} items")
128
+ return 1 if failed and failed == len(targets) else 0
129
+
130
+
131
+ def cmd_sources(args) -> int:
132
+ for name in AUTO_ORDER:
133
+ src = REGISTRY[name]
134
+ kind = "atomic" if src.produces_atomic else "sliceable"
135
+ print(f"{name:11} {src.domain:11} {kind}")
136
+ return 0
137
+
138
+
139
+ def cmd_quote(args) -> int:
140
+ cfg = config.load_config()
141
+ for key, flag in (("lines", args.lines), ("width", args.width),
142
+ ("max_lines", args.max_lines), ("max_chars", args.max_chars)):
143
+ if flag is not None:
144
+ cfg[key] = flag
145
+ text = lockscreen.quote(cfg, subject=args.subject, domain=args.domain,
146
+ source=args.source, seed=args.seed,
147
+ show_note=args.note)
148
+ if text is None:
149
+ print("No quotes stored yet — run: hyprmuse add \"Franz Kafka\"")
150
+ return 0
151
+ print(text)
152
+ return 0
153
+
154
+
155
+
156
+ def build_parser() -> argparse.ArgumentParser:
157
+ p = argparse.ArgumentParser(
158
+ prog="hyprmuse",
159
+ description="Random quotes from musicians, authors and poets, for hyprlock.")
160
+ sub = p.add_subparsers(dest="cmd")
161
+
162
+ def add_search_flags(sp):
163
+ sp.add_argument("--source", default="", choices=sorted(REGISTRY) + [""],
164
+ help="restrict to one source (default: try all)")
165
+ sp.add_argument("--lang", default="en",
166
+ help="language edition, for wikiquote (e.g. de)")
167
+
168
+ sp = sub.add_parser("add", help="harvest a new subject")
169
+ sp.add_argument("query")
170
+ add_search_flags(sp)
171
+ sp.add_argument("--yes", "-y", action="store_true",
172
+ help="take the best match without prompting")
173
+ sp.set_defaults(func=cmd_add)
174
+
175
+ sp = sub.add_parser("search", help="look up subjects without harvesting")
176
+ sp.add_argument("query")
177
+ add_search_flags(sp)
178
+ sp.set_defaults(func=cmd_search)
179
+
180
+ sp = sub.add_parser("list", help="show stored subjects")
181
+ sp.set_defaults(func=cmd_list)
182
+
183
+ sp = sub.add_parser("remove", help="delete a stored subject")
184
+ sp.add_argument("token", help="source:id or part of the name")
185
+ sp.set_defaults(func=cmd_remove)
186
+
187
+ sp = sub.add_parser("update", help="re-harvest stored subjects")
188
+ sp.add_argument("token", nargs="?", default="")
189
+ sp.add_argument("--all", action="store_true")
190
+ sp.set_defaults(func=cmd_update)
191
+
192
+ sp = sub.add_parser("sources", help="list available sources")
193
+ sp.set_defaults(func=cmd_sources)
194
+
195
+
196
+ sp = sub.add_parser("quote", help="print a random quote (default command)")
197
+ sp.add_argument("--lines", type=int, default=None,
198
+ help="lines to take from sliceable items")
199
+ sp.add_argument("--width", type=int, default=None, help="wrap width, 0 disables")
200
+ sp.add_argument("--max-lines", type=int, default=None,
201
+ help="most lines after wrapping; shorter picks are preferred")
202
+ sp.add_argument("--max-chars", type=int, default=None,
203
+ help="most characters after wrapping, 0 disables")
204
+ sp.add_argument("--subject", default="", help="restrict to one subject")
205
+ sp.add_argument("--domain", default="", help="restrict to a domain")
206
+ sp.add_argument("--source", default="", help="restrict to a source")
207
+ sp.add_argument("--note", action="store_true", help="include the citation note")
208
+ sp.add_argument("--seed", type=int, default=None, help="deterministic pick")
209
+ sp.set_defaults(func=cmd_quote)
210
+ return p
211
+
212
+
213
+ def main(argv: list[str] | None = None) -> int:
214
+ parser = build_parser()
215
+ argv = list(sys.argv[1:] if argv is None else argv)
216
+ # Bare `hyprmuse` prints a quote, so hyprlock can call it with no arguments.
217
+ if not argv or (argv[0].startswith("-") and argv[0] not in ("-h", "--help")):
218
+ argv.insert(0, "quote")
219
+ args = parser.parse_args(argv)
220
+ if not hasattr(args, "func"):
221
+ parser.print_help()
222
+ return 1
223
+ try:
224
+ return args.func(args)
225
+ except KeyboardInterrupt:
226
+ return 130
227
+ except SourceError as exc:
228
+ return _err(str(exc))
229
+
230
+
231
+ if __name__ == "__main__":
232
+ raise SystemExit(main())
hyprmuse/config.py ADDED
@@ -0,0 +1,46 @@
1
+ """XDG paths and user configuration."""
2
+
3
+ import os
4
+ import tomllib
5
+ from pathlib import Path
6
+
7
+ APP = "hyprmuse"
8
+
9
+
10
+ def _xdg(env: str, fallback: str) -> Path:
11
+ root = os.environ.get(env)
12
+ return (Path(root) if root else Path.home() / fallback) / APP
13
+
14
+
15
+ DATA_DIR = _xdg("XDG_DATA_HOME", ".local/share")
16
+ CONFIG_DIR = _xdg("XDG_CONFIG_HOME", ".config")
17
+ SUBJECTS_DIR = DATA_DIR / "subjects"
18
+ RECENT_FILE = DATA_DIR / "recent.json"
19
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
20
+
21
+ DEFAULTS = {
22
+ "lines": 3,
23
+ "width": 60,
24
+ "max_lines": 6,
25
+ "max_chars": 0, # total characters after wrapping, 0 = unlimited
26
+ "weighting": "subject", # "subject" or "quote"
27
+ "avoid_repeats": 20,
28
+ "min_line_chars": 2, # lines shorter than this ("-", "I") are skipped when slicing
29
+ }
30
+
31
+
32
+ def load_config() -> dict:
33
+ """Merge config.toml over DEFAULTS. Missing or broken file falls back cleanly."""
34
+ cfg = dict(DEFAULTS)
35
+ cfg["subjects"] = {}
36
+ if not CONFIG_FILE.exists():
37
+ return cfg
38
+ try:
39
+ raw = tomllib.loads(CONFIG_FILE.read_text(encoding="utf-8"))
40
+ except (OSError, tomllib.TOMLDecodeError):
41
+ return cfg
42
+ for key in DEFAULTS:
43
+ if key in raw.get("quote", {}):
44
+ cfg[key] = raw["quote"][key]
45
+ cfg["subjects"] = raw.get("subjects", {})
46
+ return cfg
hyprmuse/library.py ADDED
@@ -0,0 +1,111 @@
1
+ """On-disk store: one JSON file per subject under the XDG data directory."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import tempfile
7
+ import unicodedata
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from . import config
12
+ from .sources.base import Item, Subject
13
+
14
+ SCHEMA = 1
15
+
16
+
17
+ def slugify(text: str) -> str:
18
+ norm = unicodedata.normalize("NFKD", text)
19
+ norm = "".join(c for c in norm if not unicodedata.combining(c))
20
+ norm = re.sub(r"[^a-zA-Z0-9]+", "-", norm).strip("-").lower()
21
+ return norm or "unnamed"
22
+
23
+
24
+ def subject_key(source: str, source_id: str) -> str:
25
+ return f"{source}:{source_id}"
26
+
27
+
28
+ def subject_path(source: str, source_id: str) -> Path:
29
+ return config.SUBJECTS_DIR / f"{source}--{slugify(source_id)}.json"
30
+
31
+
32
+ def save(subject: Subject) -> Path:
33
+ """Write a subject atomically so a crash can never truncate the store."""
34
+ config.SUBJECTS_DIR.mkdir(parents=True, exist_ok=True)
35
+ path = subject_path(subject.source, subject.source_id)
36
+ payload = {
37
+ "schema": SCHEMA,
38
+ "source": subject.source,
39
+ "source_id": subject.source_id,
40
+ "name": subject.name,
41
+ "domain": subject.domain,
42
+ "lang": subject.lang,
43
+ "url": subject.url,
44
+ "fetched_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
45
+ "attribution": subject.attribution,
46
+ "items": [
47
+ {"work": i.work, "url": i.url, "atomic": i.atomic,
48
+ "note": i.note, "lines": i.lines}
49
+ for i in subject.items
50
+ ],
51
+ }
52
+ fd, tmp = tempfile.mkstemp(dir=config.SUBJECTS_DIR, suffix=".tmp")
53
+ try:
54
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
55
+ json.dump(payload, fh, ensure_ascii=False, indent=2)
56
+ os.replace(tmp, path)
57
+ except BaseException:
58
+ Path(tmp).unlink(missing_ok=True)
59
+ raise
60
+ return path
61
+
62
+
63
+ def _to_subject(data: dict) -> Subject:
64
+ return Subject(
65
+ source=data["source"],
66
+ source_id=data["source_id"],
67
+ name=data["name"],
68
+ domain=data.get("domain", "unknown"),
69
+ lang=data.get("lang", "en"),
70
+ url=data.get("url", ""),
71
+ attribution=data.get("attribution", {}),
72
+ items=[
73
+ Item(lines=i["lines"], work=i.get("work", ""), url=i.get("url", ""),
74
+ atomic=i.get("atomic", False), note=i.get("note", ""))
75
+ for i in data.get("items", [])
76
+ ],
77
+ )
78
+
79
+
80
+ def load_all() -> list[Subject]:
81
+ """Every stored subject. Unreadable files are skipped, not fatal."""
82
+ if not config.SUBJECTS_DIR.is_dir():
83
+ return []
84
+ out = []
85
+ for path in sorted(config.SUBJECTS_DIR.glob("*.json")):
86
+ try:
87
+ out.append(_to_subject(json.loads(path.read_text(encoding="utf-8"))))
88
+ except (OSError, ValueError, KeyError):
89
+ continue
90
+ return out
91
+
92
+
93
+ def find(token: str) -> list[Subject]:
94
+ """Resolve 'source:id', or a case-insensitive substring of the name."""
95
+ subjects = load_all()
96
+ if ":" in token:
97
+ src, _, sid = token.partition(":")
98
+ exact = [s for s in subjects
99
+ if s.source == src and s.source_id == sid]
100
+ if exact:
101
+ return exact
102
+ low = token.lower()
103
+ return [s for s in subjects if low in s.name.lower()]
104
+
105
+
106
+ def remove(subject: Subject) -> bool:
107
+ path = subject_path(subject.source, subject.source_id)
108
+ if path.exists():
109
+ path.unlink()
110
+ return True
111
+ return False
hyprmuse/lockscreen.py ADDED
@@ -0,0 +1,224 @@
1
+ """The quote hyprlock shows: drawn from the library, fitted to a box, attributed.
2
+
3
+ Everything about the box (wrapping, line and character caps, what happens when
4
+ nothing fits) and about repeat avoidance lives here, behind ``quote``.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ import random
10
+ import textwrap
11
+ from dataclasses import dataclass
12
+
13
+ from . import config, library
14
+ from .sources.base import Item, Subject
15
+ from .text import stanzas, wrap
16
+
17
+ DASH = "—"
18
+
19
+
20
+ def quote(cfg: dict, *, subject: str = "", domain: str = "", source: str = "",
21
+ seed: int | None = None, show_note: bool = False) -> str | None:
22
+ """A random quote from the library, rendered as text for the lockscreen.
23
+
24
+ ``cfg`` is a config dict shaped like ``config.load_config()``'s: the keys
25
+ of ``config.DEFAULTS`` (each documented there) plus ``"subjects"``, the
26
+ per-subject ``enabled``/``weight`` table. A caller overriding a setting
27
+ replaces its key before the call.
28
+
29
+ ``subject`` (a name substring or a ``source:id`` key), ``domain`` and
30
+ ``source`` restrict which stored subjects are drawn from; empty means any.
31
+ ``seed`` makes the draw deterministic and leaves the repeat history alone.
32
+ ``show_note`` appends the item's citation note, when it has one.
33
+
34
+ The result is the quote body, a blank line, then "— Name" or "— Name: Work",
35
+ then the note if shown. The body fits the box ``cfg`` describes; when no
36
+ stored quote fits, the best effort is trimmed to it, and only then can a
37
+ line end in "…". Returns None when no stored subject passes the filters,
38
+ or none of their items has a line longer than ``cfg["min_line_chars"]``.
39
+
40
+ Unless ``seed`` is set, records the quote in ``config.RECENT_FILE`` so the
41
+ next ``cfg["avoid_repeats"]`` draws avoid it; a failure to write that file
42
+ is ignored, since a read-only home must not break the lockscreen.
43
+ """
44
+ pool = _candidates(cfg, subject=subject, domain=domain, source=source)
45
+ if not pool:
46
+ return None
47
+ rng = random.Random(seed)
48
+ recent = _load_recent()
49
+ keep = int(cfg.get("avoid_repeats", 20))
50
+ weights = [_weight(s, cfg) for s in pool]
51
+ if sum(weights) <= 0:
52
+ weights = [1.0] * len(pool)
53
+ fit = _Fit(width=int(cfg["width"]), max_lines=int(cfg["max_lines"]),
54
+ max_chars=int(cfg["max_chars"]))
55
+
56
+ draw = dict(num_lines=int(cfg["lines"]),
57
+ min_chars=int(cfg.get("min_line_chars", 0)),
58
+ recent=recent if keep > 0 else [])
59
+ chosen = _draw(pool, weights, rng, fit=fit, **draw)
60
+ if chosen is None and fit != _UNCONSTRAINED:
61
+ # Nothing in the library fits the box; a trimmed quote beats a blank
62
+ # lockscreen, so draw without it and let _render trim the result.
63
+ chosen = _draw(pool, weights, rng, fit=_UNCONSTRAINED, **draw)
64
+ if chosen is None:
65
+ return None
66
+
67
+ if keep > 0 and seed is None:
68
+ _save_recent(recent + [chosen.fingerprint()], keep)
69
+ return _render(chosen, fit, show_note=show_note)
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class _Fit:
74
+ """The box a rendered quote has to fit in. Zero means unconstrained."""
75
+
76
+ width: int = 0
77
+ max_lines: int = 0
78
+ max_chars: int = 0 # summed over the wrapped lines, newlines not counted
79
+
80
+ def accepts(self, lines: list[str]) -> bool:
81
+ body = wrap(lines, self.width)
82
+ if self.max_lines > 0 and len(body) > self.max_lines:
83
+ return False
84
+ if self.max_chars > 0 and sum(len(ln) for ln in body) > self.max_chars:
85
+ return False
86
+ return True
87
+
88
+ def trim(self, lines: list[str]) -> list[str]:
89
+ """Wrap ``lines`` and cut them to the box, keeping at least one line.
90
+
91
+ Whole lines go from the end first; only a single line still over
92
+ max_chars is cut inside, at a word boundary, and marked with "…".
93
+ """
94
+ body = wrap(lines, self.width)
95
+ if self.max_lines > 0:
96
+ body = body[:self.max_lines]
97
+ if self.max_chars <= 0:
98
+ return body
99
+ while len(body) > 1 and sum(len(ln) for ln in body) > self.max_chars:
100
+ body = body[:-1]
101
+ if body and len(body[0]) > self.max_chars:
102
+ short = textwrap.shorten(body[0], width=self.max_chars, placeholder="…")
103
+ # shorten() yields the bare placeholder when the first word alone
104
+ # is too long; a hard cut keeps some text instead.
105
+ if short == "…":
106
+ short = body[0][:self.max_chars - 1] + "…"
107
+ body = [short]
108
+ return body
109
+
110
+
111
+ _UNCONSTRAINED = _Fit()
112
+
113
+
114
+ @dataclass
115
+ class _Pick:
116
+ """One drawn quote: the lines taken from ``item``, before wrapping."""
117
+
118
+ subject: Subject
119
+ item: Item
120
+ lines: list[str]
121
+
122
+ def fingerprint(self) -> str:
123
+ """Identifies the quote in the repeat history; equal text, equal print."""
124
+ seed = f"{self.subject.source}:{self.subject.source_id}:" + "|".join(self.lines)
125
+ return hashlib.sha1(seed.encode("utf-8")).hexdigest()[:16]
126
+
127
+
128
+ def _render(pick: _Pick, fit: _Fit, *, show_note: bool) -> str:
129
+ who = pick.subject.name
130
+ work = pick.item.work.strip()
131
+ parts = ["\n".join(fit.trim(pick.lines)), "",
132
+ f"{DASH} {who}: {work}" if work else f"{DASH} {who}"]
133
+ if show_note and pick.item.note:
134
+ parts.extend(wrap([pick.item.note], fit.width))
135
+ return "\n".join(parts)
136
+
137
+
138
+ def _load_recent() -> list[str]:
139
+ try:
140
+ return json.loads(config.RECENT_FILE.read_text(encoding="utf-8"))
141
+ except (OSError, ValueError):
142
+ return []
143
+
144
+
145
+ def _save_recent(seen: list[str], keep: int) -> None:
146
+ try:
147
+ config.DATA_DIR.mkdir(parents=True, exist_ok=True)
148
+ config.RECENT_FILE.write_text(json.dumps(seen[-keep:]), encoding="utf-8")
149
+ except OSError:
150
+ pass # a read-only home must not break the lockscreen
151
+
152
+
153
+ def _candidates(cfg: dict, *, subject: str, domain: str,
154
+ source: str) -> list[Subject]:
155
+ """Stored subjects with items, minus those disabled in config or filtered out."""
156
+ out = []
157
+ for subj in library.load_all():
158
+ settings = cfg.get("subjects", {}).get(
159
+ library.subject_key(subj.source, subj.source_id), {})
160
+ if settings.get("enabled") is False:
161
+ continue
162
+ if domain and subj.domain != domain:
163
+ continue
164
+ if source and subj.source != source:
165
+ continue
166
+ if subject and subject.lower() not in subj.name.lower() \
167
+ and subject != library.subject_key(subj.source, subj.source_id):
168
+ continue
169
+ if subj.items:
170
+ out.append(subj)
171
+ return out
172
+
173
+
174
+ def _weight(subj: Subject, cfg: dict) -> float:
175
+ settings = cfg.get("subjects", {}).get(
176
+ library.subject_key(subj.source, subj.source_id), {})
177
+ base = float(settings.get("weight", 1.0))
178
+ # "quote" weighting makes every stored quote equally likely, so prolific
179
+ # subjects dominate; "subject" (the default) gives each subject equal say.
180
+ return base * len(subj.items) if cfg.get("weighting") == "quote" else base
181
+
182
+
183
+ def _extract(item: Item, num_lines: int, rng: random.Random, *,
184
+ min_chars: int, fit: _Fit) -> list[str]:
185
+ """Lines to quote from one item, or [] if nothing from it fits.
186
+
187
+ Sliceable items are cut within a single stanza (blank-line delimited), so
188
+ a window never straddles a verse and its chorus. If the window overflows
189
+ the fit it is shortened from the end, a line at a time, before giving up.
190
+ """
191
+ groups = stanzas(item.lines, min_chars)
192
+ if not groups:
193
+ return []
194
+ if item.atomic:
195
+ whole = [ln for g in groups for ln in g]
196
+ return whole if fit.accepts(whole) else []
197
+ # A one-line stanza is usually a stray refrain tag or an orphaned line;
198
+ # prefer real stanzas whenever the item has any.
199
+ usable = [g for g in groups if len(g) >= 2] or groups
200
+ stanza = rng.choice(usable)
201
+ take = min(num_lines, len(stanza))
202
+ start = rng.randint(0, len(stanza) - take)
203
+ for n in range(take, 0, -1):
204
+ window = stanza[start:start + n]
205
+ if fit.accepts(window):
206
+ return window
207
+ return []
208
+
209
+
210
+ def _draw(pool: list[Subject], weights: list[float], rng: random.Random, *,
211
+ num_lines: int, min_chars: int, fit: _Fit, recent: list[str],
212
+ tries: int = 40) -> _Pick | None:
213
+ """Weighted random pick, retrying briefly to avoid a recently shown one."""
214
+ chosen = None
215
+ for _ in range(tries):
216
+ subj = rng.choices(pool, weights=weights, k=1)[0]
217
+ item = rng.choice(subj.items)
218
+ lines = _extract(item, num_lines, rng, min_chars=min_chars, fit=fit)
219
+ if not lines:
220
+ continue
221
+ chosen = _Pick(subj, item, lines)
222
+ if chosen.fingerprint() not in recent:
223
+ break
224
+ return chosen
@@ -0,0 +1,25 @@
1
+ """Registry of quote sources."""
2
+
3
+ from . import genius, gutenberg, poetrydb, wikiquote
4
+ from .base import Candidate, Item, Source, SourceError, Subject
5
+
6
+ REGISTRY: dict[str, Source] = {
7
+ s.SOURCE.name: s.SOURCE for s in (genius, wikiquote, poetrydb, gutenberg)
8
+ }
9
+
10
+ # Order tried when the user does not name a source explicitly. Wikiquote first:
11
+ # it has the broadest subject coverage across every domain.
12
+ AUTO_ORDER = ["wikiquote", "genius", "poetrydb", "gutenberg"]
13
+
14
+
15
+ def get_source(name: str) -> Source:
16
+ try:
17
+ return REGISTRY[name]
18
+ except KeyError:
19
+ raise SourceError(
20
+ f"unknown source '{name}' (have: {', '.join(sorted(REGISTRY))})"
21
+ ) from None
22
+
23
+
24
+ __all__ = ["REGISTRY", "AUTO_ORDER", "get_source", "Candidate", "Item",
25
+ "Source", "SourceError", "Subject"]