antilibrary 0.1.0__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: antilibrary
3
+ Version: 0.1.0
4
+ Summary: Manage BibTeX libraries from the terminal
5
+ Project-URL: Homepage, https://github.com/vxvware/antilibrary
6
+ Project-URL: Repository, https://github.com/vxvware/antilibrary
7
+ Project-URL: Issues, https://github.com/vxvware/antilibrary/issues
8
+ Author-email: VxVware <trevor.j.vincent@gmail.com>
9
+ License-Expression: MIT
10
+ Keywords: bibliography,bibtex,citations,cli,research
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Text Processing :: Markup :: LaTeX
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: bibtexparser>=2.0.0b7
24
+ Requires-Dist: rapidfuzz>=3.0
25
+ Requires-Dist: rich>=13.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.4; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # antilibrary
32
+
33
+ Manage a BibTeX file from the terminal.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install antilibrary
39
+ # or
40
+ uv tool install antilibrary
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ Antilibrary operates on a single `.bib` file resolved in this order:
46
+
47
+ 1. `-f / --file FILE` argument
48
+ 2. `$ANTILIBRARY_BIB` environment variable
49
+
50
+ ```bash
51
+ export ANTILIBRARY_BIB=~/refs/main.bib
52
+
53
+ antilibrary add # interactively add an entry
54
+ antilibrary get smith2024 # print citation for a key
55
+ antilibrary browse # list entries
56
+ antilibrary browse -q "attention" # fuzzy search
57
+ antilibrary browse -t "nlp,transformers" # filter by tags
58
+ antilibrary remove smith2024 # delete an entry
59
+ ```
60
+
61
+ ### Citation styles
62
+
63
+ `antilibrary get KEY --style {bibtex|bibtex-full|latex|org-cite|pandoc}` prints
64
+ a citation in the requested format:
65
+
66
+ | style | output |
67
+ |----------------|-------------------------|
68
+ | `bibtex` | `smith2024` |
69
+ | `bibtex-full` | the whole entry |
70
+ | `latex` | `\cite{smith2024}` |
71
+ | `org-cite` | `[cite:@smith2024]` |
72
+ | `pandoc` | `[@smith2024]` |
73
+
74
+ ## Adding entries
75
+
76
+ `antilibrary add` is interactive — it prompts for type, title, author, year,
77
+ key, and any extra fields. Adding an entry that matches an existing one
78
+ (by DOI, arXiv ID, ISBN, or normalized title+author) silently fills any
79
+ empty fields on the existing entry rather than creating a duplicate. Pass
80
+ `--no-merge` to skip on duplicate, or `--force` to append anyway with an
81
+ auto-suffixed key.
82
+
83
+ Companion tools (planned):
84
+
85
+ - **bibzapper** — batch extraction from PDFs, EPUBs, URLs, DOIs, arXiv IDs, or
86
+ ISBNs into a `.bib` file.
87
+ - **bibsearcher** — online search (movies, TV, music, papers, books) → BibTeX.
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,61 @@
1
+ # antilibrary
2
+
3
+ Manage a BibTeX file from the terminal.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install antilibrary
9
+ # or
10
+ uv tool install antilibrary
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ Antilibrary operates on a single `.bib` file resolved in this order:
16
+
17
+ 1. `-f / --file FILE` argument
18
+ 2. `$ANTILIBRARY_BIB` environment variable
19
+
20
+ ```bash
21
+ export ANTILIBRARY_BIB=~/refs/main.bib
22
+
23
+ antilibrary add # interactively add an entry
24
+ antilibrary get smith2024 # print citation for a key
25
+ antilibrary browse # list entries
26
+ antilibrary browse -q "attention" # fuzzy search
27
+ antilibrary browse -t "nlp,transformers" # filter by tags
28
+ antilibrary remove smith2024 # delete an entry
29
+ ```
30
+
31
+ ### Citation styles
32
+
33
+ `antilibrary get KEY --style {bibtex|bibtex-full|latex|org-cite|pandoc}` prints
34
+ a citation in the requested format:
35
+
36
+ | style | output |
37
+ |----------------|-------------------------|
38
+ | `bibtex` | `smith2024` |
39
+ | `bibtex-full` | the whole entry |
40
+ | `latex` | `\cite{smith2024}` |
41
+ | `org-cite` | `[cite:@smith2024]` |
42
+ | `pandoc` | `[@smith2024]` |
43
+
44
+ ## Adding entries
45
+
46
+ `antilibrary add` is interactive — it prompts for type, title, author, year,
47
+ key, and any extra fields. Adding an entry that matches an existing one
48
+ (by DOI, arXiv ID, ISBN, or normalized title+author) silently fills any
49
+ empty fields on the existing entry rather than creating a duplicate. Pass
50
+ `--no-merge` to skip on duplicate, or `--force` to append anyway with an
51
+ auto-suffixed key.
52
+
53
+ Companion tools (planned):
54
+
55
+ - **bibzapper** — batch extraction from PDFs, EPUBs, URLs, DOIs, arXiv IDs, or
56
+ ISBNs into a `.bib` file.
57
+ - **bibsearcher** — online search (movies, TV, music, papers, books) → BibTeX.
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "antilibrary"
3
+ version = "0.1.0"
4
+ description = "Manage BibTeX libraries from the terminal"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ authors = [
9
+ { name = "VxVware", email = "trevor.j.vincent@gmail.com" },
10
+ ]
11
+ keywords = ["bibtex", "bibliography", "citations", "cli", "research"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Science/Research",
16
+ "Intended Audience :: End Users/Desktop",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Text Processing :: Markup :: LaTeX",
23
+ "Topic :: Utilities",
24
+ ]
25
+ dependencies = [
26
+ "bibtexparser>=2.0.0b7",
27
+ "rapidfuzz>=3.0",
28
+ "rich>=13.0",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0",
34
+ "ruff>=0.4",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/vxvware/antilibrary"
39
+ Repository = "https://github.com/vxvware/antilibrary"
40
+ Issues = "https://github.com/vxvware/antilibrary/issues"
41
+
42
+ [project.scripts]
43
+ antilibrary = "antilibrary.cli.app:main"
44
+
45
+ [build-system]
46
+ requires = ["hatchling"]
47
+ build-backend = "hatchling.build"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/antilibrary"]
51
+
52
+ [tool.ruff]
53
+ line-length = 100
@@ -0,0 +1,3 @@
1
+ """Antilibrary - Manage BibTeX libraries from the terminal."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,137 @@
1
+ """Read, dedup, and append BibTeX entries to a .bib file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import bibtexparser
8
+
9
+ from antilibrary.entry import Entry
10
+
11
+
12
+ def _btp_to_entry(e, source: Path | None = None) -> Entry:
13
+ fields = {f.key: f.value for f in e.fields}
14
+ return Entry(key=e.key, entry_type=e.entry_type, fields=fields, source_file=source)
15
+
16
+
17
+ def load(path: Path) -> list[Entry]:
18
+ """Load all entries from a .bib file. Returns [] if missing/empty."""
19
+ if not path.exists():
20
+ return []
21
+ text = path.read_text()
22
+ if not text.strip():
23
+ return []
24
+ lib = bibtexparser.parse_string(text)
25
+ return [_btp_to_entry(e, path) for e in lib.entries]
26
+
27
+
28
+ def _norm(s: str) -> str:
29
+ return s.strip().lower()
30
+
31
+
32
+ def find_duplicate(entry: Entry, existing: list[Entry]) -> Entry | None:
33
+ """Return an existing entry that matches by identifier or title+author."""
34
+ new_doi = _norm(entry.fields.get("doi", ""))
35
+ new_arxiv = _norm(entry.fields.get("eprint", ""))
36
+ new_isbn = _norm(entry.fields.get("isbn", ""))
37
+ new_title = _norm(entry.fields.get("title", ""))
38
+ new_author = _norm(entry.fields.get("author", ""))
39
+
40
+ for e in existing:
41
+ if e.key == entry.key:
42
+ return e
43
+ if new_doi and _norm(e.fields.get("doi", "")) == new_doi:
44
+ return e
45
+ if new_arxiv and _norm(e.fields.get("eprint", "")) == new_arxiv:
46
+ return e
47
+ if new_isbn and _norm(e.fields.get("isbn", "")) == new_isbn:
48
+ return e
49
+ if (
50
+ new_title
51
+ and _norm(e.fields.get("title", "")) == new_title
52
+ and new_author
53
+ and _norm(e.fields.get("author", "")) == new_author
54
+ ):
55
+ return e
56
+ return None
57
+
58
+
59
+ def make_unique_key(base: str, existing: list[Entry]) -> str:
60
+ """Suffix a, b, c... if `base` collides with any key in existing."""
61
+ keys = {e.key for e in existing}
62
+ if base not in keys:
63
+ return base
64
+ for suffix in "abcdefghijklmnopqrstuvwxyz":
65
+ candidate = f"{base}{suffix}"
66
+ if candidate not in keys:
67
+ return candidate
68
+ n = 1
69
+ while f"{base}{n}" in keys:
70
+ n += 1
71
+ return f"{base}{n}"
72
+
73
+
74
+ def _append_entry(path: Path, entry: Entry) -> None:
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ text = path.read_text() if path.exists() else ""
77
+ separator = "\n\n" if text.strip() else ""
78
+ with open(path, "a") as f:
79
+ f.write(separator + entry.to_bibtex() + "\n")
80
+ entry.source_file = path
81
+
82
+
83
+ def _rewrite(path: Path, entries: list[Entry]) -> None:
84
+ content = "\n\n".join(e.to_bibtex() for e in entries)
85
+ path.write_text(content + "\n" if content else "")
86
+
87
+
88
+ def merge_missing(target: Entry, source: Entry) -> bool:
89
+ """Fill empty/absent fields on `target` from `source`. Returns True if changed."""
90
+ changed = False
91
+ for k, v in source.fields.items():
92
+ if not v:
93
+ continue
94
+ if not target.fields.get(k, "").strip():
95
+ target.fields[k] = v
96
+ changed = True
97
+ return changed
98
+
99
+
100
+ class AppendResult:
101
+ __slots__ = ("status", "entry", "duplicate_of")
102
+
103
+ def __init__(self, status: str, entry: Entry, duplicate_of: Entry | None = None):
104
+ self.status = status # "added" | "skipped" | "merged"
105
+ self.entry = entry
106
+ self.duplicate_of = duplicate_of
107
+
108
+
109
+ def append(
110
+ path: Path,
111
+ entry: Entry,
112
+ *,
113
+ merge: bool = True,
114
+ force: bool = False,
115
+ ) -> AppendResult:
116
+ """Append `entry` to the .bib at `path` with dedup.
117
+
118
+ - If a duplicate exists and `force` is False:
119
+ - If `merge` is True (default), fill missing/empty fields on the existing entry
120
+ and rewrite the file. Returns status="merged" if anything changed,
121
+ otherwise "skipped".
122
+ - If `merge` is False, returns status="skipped".
123
+ - Otherwise auto-suffixes the key on collision and appends.
124
+ """
125
+ existing = load(path)
126
+ dup = find_duplicate(entry, existing) if not force else None
127
+
128
+ if dup is not None:
129
+ if merge:
130
+ if merge_missing(dup, entry):
131
+ _rewrite(path, existing)
132
+ return AppendResult("merged", dup, duplicate_of=dup)
133
+ return AppendResult("skipped", dup, duplicate_of=dup)
134
+
135
+ entry.key = make_unique_key(entry.key, existing)
136
+ _append_entry(path, entry)
137
+ return AppendResult("added", entry)
File without changes
@@ -0,0 +1,234 @@
1
+ """Antilibrary CLI — manage a BibTeX file.
2
+
3
+ Commands operate on a single .bib file resolved in this order:
4
+ 1. -f/--file FILE argument
5
+ 2. $ANTILIBRARY_BIB environment variable
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from rich import box
16
+ from rich.console import Console
17
+ from rich.syntax import Syntax
18
+ from rich.table import Table
19
+
20
+ from antilibrary.core import Library, make_entry
21
+ from antilibrary.formatters import format_citation
22
+ from antilibrary.search import fuzzy_search, filter_by_tags
23
+
24
+ console = Console()
25
+
26
+
27
+ def _resolve_file(arg: str | None) -> Path:
28
+ if arg:
29
+ return Path(arg).expanduser()
30
+ env = os.environ.get("ANTILIBRARY_BIB")
31
+ if env:
32
+ return Path(env).expanduser()
33
+ console.print(
34
+ "[red]no .bib file specified[/] "
35
+ "[dim]pass -f FILE or set ANTILIBRARY_BIB[/]"
36
+ )
37
+ sys.exit(2)
38
+
39
+
40
+ def _prompt(label: str, default: str = "") -> str:
41
+ suffix = f" [{default}]" if default else ""
42
+ try:
43
+ val = input(f"{label}{suffix}: ").strip()
44
+ except (EOFError, KeyboardInterrupt):
45
+ print()
46
+ sys.exit(130)
47
+ return val or default
48
+
49
+
50
+ # ── add ─────────────────────────────────────────────────────────────────────
51
+
52
+
53
+ def cmd_add(args: argparse.Namespace) -> int:
54
+ path = _resolve_file(args.file)
55
+ lib = Library(path)
56
+
57
+ entry_type = args.type or _prompt("type", "article")
58
+ title = _prompt("title")
59
+ if not title:
60
+ console.print("[red]title required[/]")
61
+ return 2
62
+ author = _prompt("author")
63
+ year = _prompt("year")
64
+ key = args.key or _prompt("key (blank = auto)")
65
+
66
+ entry = make_entry(
67
+ entry_type=entry_type,
68
+ title=title,
69
+ author=author,
70
+ year=year or None,
71
+ key=key or None,
72
+ )
73
+
74
+ while True:
75
+ extra = _prompt("extra field (blank to finish)")
76
+ if not extra:
77
+ break
78
+ val = _prompt(f" {extra}")
79
+ if val:
80
+ entry.fields[extra] = val
81
+
82
+ result = lib.add(entry, merge=not args.no_merge, force=args.force)
83
+ if result.status == "added":
84
+ console.print(f"[green]added[/] [bold]{result.entry.key}[/] → {path}")
85
+ elif result.status == "merged":
86
+ console.print(f"[green]merged[/] missing fields into [bold]{result.entry.key}[/]")
87
+ else:
88
+ console.print(f"[yellow]duplicate[/] of [bold]{result.duplicate_of.key}[/] — skipped")
89
+ return 0
90
+
91
+
92
+ # ── get ─────────────────────────────────────────────────────────────────────
93
+
94
+
95
+ def cmd_get(args: argparse.Namespace) -> int:
96
+ path = _resolve_file(args.file)
97
+ lib = Library(path)
98
+ entry = lib.find(args.key)
99
+ if entry is None:
100
+ console.print(f"[red]no entry with key {args.key!r}[/]")
101
+ return 1
102
+
103
+ if args.style == "bibtex-full":
104
+ console.print(
105
+ Syntax(entry.to_bibtex(), "bibtex", theme="monokai", line_numbers=False, padding=(0, 1))
106
+ )
107
+ else:
108
+ print(format_citation(entry, args.style))
109
+ return 0
110
+
111
+
112
+ # ── browse ──────────────────────────────────────────────────────────────────
113
+
114
+
115
+ def cmd_browse(args: argparse.Namespace) -> int:
116
+ path = _resolve_file(args.file)
117
+ lib = Library(path)
118
+ entries = lib.entries()
119
+
120
+ if args.tags:
121
+ tags = [t.strip() for t in args.tags.split(",") if t.strip()]
122
+ entries = filter_by_tags(entries, tags)
123
+
124
+ if args.query:
125
+ entries = fuzzy_search(entries, args.query, limit=args.limit)
126
+ else:
127
+ entries = entries[: args.limit]
128
+
129
+ if not entries:
130
+ console.print("[dim]no matching entries[/]")
131
+ return 0
132
+
133
+ table = Table(box=box.SIMPLE_HEAVY, header_style="bold cyan", padding=(0, 1))
134
+ table.add_column("key", style="bold cyan", max_width=28)
135
+ table.add_column("title", ratio=3)
136
+ table.add_column("author", ratio=2)
137
+ table.add_column("year", width=6, justify="center")
138
+ table.add_column("tags", style="blue", max_width=20)
139
+
140
+ for e in entries:
141
+ table.add_row(
142
+ e.key,
143
+ e.fields.get("title", ""),
144
+ e.fields.get("author", "")[:40],
145
+ e.fields.get("year", ""),
146
+ ", ".join(e.tags) if e.tags else "",
147
+ )
148
+ console.print(table)
149
+ console.print(f"[dim]{len(entries)} entries · {path}[/]")
150
+ return 0
151
+
152
+
153
+ # ── remove ──────────────────────────────────────────────────────────────────
154
+
155
+
156
+ def cmd_remove(args: argparse.Namespace) -> int:
157
+ path = _resolve_file(args.file)
158
+ lib = Library(path)
159
+ entry = lib.find(args.key)
160
+ if entry is None:
161
+ console.print(f"[red]no entry with key {args.key!r}[/]")
162
+ return 1
163
+
164
+ if not args.yes:
165
+ confirm = _prompt(f"remove {entry.display_string()!r}? (y/N)", "n")
166
+ if confirm.lower() not in ("y", "yes"):
167
+ console.print("[dim]cancelled[/]")
168
+ return 0
169
+
170
+ lib.remove(args.key)
171
+ console.print(f"[green]removed[/] {args.key}")
172
+ return 0
173
+
174
+
175
+ # ── argparse wiring ─────────────────────────────────────────────────────────
176
+
177
+
178
+ def build_parser() -> argparse.ArgumentParser:
179
+ p = argparse.ArgumentParser(
180
+ prog="antilibrary",
181
+ description="Manage a BibTeX file.",
182
+ )
183
+ sub = p.add_subparsers(dest="command", required=True)
184
+
185
+ def add_file(sp: argparse.ArgumentParser) -> None:
186
+ sp.add_argument(
187
+ "-f", "--file",
188
+ help="Path to .bib file (default: $ANTILIBRARY_BIB)",
189
+ )
190
+
191
+ sp_add = sub.add_parser("add", help="Add a new entry interactively")
192
+ add_file(sp_add)
193
+ sp_add.add_argument("--type", help="Entry type (default: article)")
194
+ sp_add.add_argument("--key", help="Citation key (default: auto-generated)")
195
+ sp_add.add_argument("--no-merge", action="store_true",
196
+ help="On duplicate, skip without filling missing fields")
197
+ sp_add.add_argument("--force", action="store_true",
198
+ help="Append even if a duplicate exists")
199
+ sp_add.set_defaults(func=cmd_add)
200
+
201
+ sp_get = sub.add_parser("get", help="Fetch an entry by key")
202
+ add_file(sp_get)
203
+ sp_get.add_argument("key")
204
+ sp_get.add_argument(
205
+ "--style",
206
+ choices=("bibtex", "bibtex-full", "latex", "org-cite", "pandoc"),
207
+ default="bibtex-full",
208
+ help="Output format (default: bibtex-full — the whole entry)",
209
+ )
210
+ sp_get.set_defaults(func=cmd_get)
211
+
212
+ sp_br = sub.add_parser("browse", help="List / search entries")
213
+ add_file(sp_br)
214
+ sp_br.add_argument("--query", "-q", help="Fuzzy-match query")
215
+ sp_br.add_argument("--tags", "-t", help="Filter by tags (comma-separated)")
216
+ sp_br.add_argument("--limit", type=int, default=50)
217
+ sp_br.set_defaults(func=cmd_browse)
218
+
219
+ sp_rm = sub.add_parser("remove", help="Remove an entry by key")
220
+ add_file(sp_rm)
221
+ sp_rm.add_argument("key")
222
+ sp_rm.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
223
+ sp_rm.set_defaults(func=cmd_remove)
224
+
225
+ return p
226
+
227
+
228
+ def main(argv: list[str] | None = None) -> int:
229
+ args = build_parser().parse_args(argv)
230
+ return args.func(args)
231
+
232
+
233
+ if __name__ == "__main__":
234
+ sys.exit(main())
@@ -0,0 +1,86 @@
1
+ """Core BibTeX entry/library management.
2
+
3
+ `Entry` and `bibfile` provide the data class and low-level .bib I/O;
4
+ this module wraps them in a higher-level `Library` used across antilibrary's
5
+ CLI and search.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from datetime import datetime
12
+
13
+ from antilibrary import bibfile
14
+ from antilibrary.entry import Entry
15
+
16
+ __all__ = ["Entry", "Library", "make_entry"]
17
+
18
+
19
+ class Library:
20
+ """A BibTeX library backed by a .bib file."""
21
+
22
+ def __init__(self, path: Path, label: str | None = None):
23
+ self.path = path
24
+ self.label = label or path.stem
25
+ self._entries: list[Entry] | None = None
26
+
27
+ def entries(self, *, reload: bool = False) -> list[Entry]:
28
+ if self._entries is not None and not reload:
29
+ return self._entries
30
+ self._entries = bibfile.load(self.path)
31
+ return self._entries
32
+
33
+ def find(self, key: str) -> Entry | None:
34
+ for e in self.entries():
35
+ if e.key == key:
36
+ return e
37
+ return None
38
+
39
+ def has_duplicate(self, entry: Entry) -> Entry | None:
40
+ return bibfile.find_duplicate(entry, self.entries())
41
+
42
+ def add(self, entry: Entry, *, merge: bool = True, force: bool = False) -> bibfile.AppendResult:
43
+ """Append an entry, deduplicating against existing ones."""
44
+ result = bibfile.append(self.path, entry, merge=merge, force=force)
45
+ self._entries = None
46
+ return result
47
+
48
+ def remove(self, key: str) -> bool:
49
+ """Remove an entry by key. Rewrites the file."""
50
+ entries = self.entries(reload=True)
51
+ filtered = [e for e in entries if e.key != key]
52
+ if len(filtered) == len(entries):
53
+ return False
54
+ from antilibrary.bibfile import _rewrite
55
+ _rewrite(self.path, filtered)
56
+ self._entries = None
57
+ return True
58
+
59
+ @property
60
+ def count(self) -> int:
61
+ return len(self.entries())
62
+
63
+
64
+ def make_entry(
65
+ entry_type: str = "misc",
66
+ title: str = "Untitled",
67
+ author: str = "",
68
+ year: str | None = None,
69
+ key: str | None = None,
70
+ **extra_fields: str,
71
+ ) -> Entry:
72
+ """Create an Entry with sensible defaults."""
73
+ if year is None:
74
+ year = str(datetime.now().year)
75
+ if key is None:
76
+ if author:
77
+ last = author.split(",")[0].split()[-1].lower() if author else "unknown"
78
+ else:
79
+ last = "unknown"
80
+ safe_title = "".join(c for c in title[:20] if c.isalnum())
81
+ key = f"{last}{year}{safe_title}".lower()
82
+ fields = {"title": title, "year": year}
83
+ if author:
84
+ fields["author"] = author
85
+ fields.update(extra_fields)
86
+ return Entry(key=key, entry_type=entry_type, fields=fields)
@@ -0,0 +1 @@
1
+ """External database integrations (OMDB, MusicBrainz, etc.) - to be implemented."""
@@ -0,0 +1,45 @@
1
+ """BibTeX Entry data class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass
10
+ class Entry:
11
+ """A single BibTeX entry."""
12
+
13
+ key: str
14
+ entry_type: str
15
+ fields: dict[str, str] = field(default_factory=dict)
16
+ raw: str = ""
17
+ source_file: Path | None = None
18
+
19
+ def display_string(self) -> str:
20
+ author = self.fields.get("author", "Unknown")
21
+ if "," in author:
22
+ author = author.split(",")[0].strip()
23
+ elif " and " in author:
24
+ author = author.split(" and ")[0].strip()
25
+ year = self.fields.get("year", "n.d.")
26
+ title = self.fields.get("title", "Untitled")
27
+ return f"{author} ({year}) - {title}"
28
+
29
+ @property
30
+ def tags(self) -> list[str]:
31
+ kw = self.fields.get("keywords", "")
32
+ if not kw:
33
+ return []
34
+ return [t.strip() for t in kw.split(",") if t.strip()]
35
+
36
+ @tags.setter
37
+ def tags(self, value: list[str]) -> None:
38
+ self.fields["keywords"] = ", ".join(value)
39
+
40
+ def to_bibtex(self) -> str:
41
+ lines = [f"@{self.entry_type}{{{self.key},"]
42
+ for k, v in self.fields.items():
43
+ lines.append(f" {k} = {{{v}}},")
44
+ lines.append("}")
45
+ return "\n".join(lines)
@@ -0,0 +1,26 @@
1
+ """Output formatting for BibTeX entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from antilibrary.core import Entry
6
+
7
+
8
+ def format_citation(entry: Entry, style: str = "bibtex") -> str:
9
+ """Format a citation reference in the given style."""
10
+ key = entry.key
11
+ match style:
12
+ case "bibtex":
13
+ return key
14
+ case "latex":
15
+ return f"\\cite{{{key}}}"
16
+ case "org-cite":
17
+ return f"[cite:@{key}]"
18
+ case "pandoc":
19
+ return f"[@{key}]"
20
+ case _:
21
+ return key
22
+
23
+
24
+ def format_display(entry: Entry) -> str:
25
+ """Format entry for display in search results."""
26
+ return entry.display_string()
@@ -0,0 +1,50 @@
1
+ """Fuzzy search over BibTeX entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rapidfuzz import fuzz, process
6
+
7
+ from antilibrary.core import Entry
8
+
9
+
10
+ def fuzzy_search(
11
+ entries: list[Entry],
12
+ query: str,
13
+ limit: int = 30,
14
+ score_cutoff: float = 30.0,
15
+ ) -> list[Entry]:
16
+ """Fuzzy search entries by their display string.
17
+
18
+ Returns entries sorted by match quality (best first).
19
+ """
20
+ if not query.strip():
21
+ return entries[:limit]
22
+
23
+ display_map = {e.display_string(): e for e in entries}
24
+ results = process.extract(
25
+ query,
26
+ display_map.keys(),
27
+ scorer=fuzz.WRatio,
28
+ limit=limit,
29
+ score_cutoff=score_cutoff,
30
+ )
31
+ return [display_map[r[0]] for r in results]
32
+
33
+
34
+ def filter_by_tags(entries: list[Entry], tags: list[str]) -> list[Entry]:
35
+ """Filter entries that have ALL of the specified tags."""
36
+ if not tags:
37
+ return entries
38
+ tag_set = {t.lower() for t in tags}
39
+ return [
40
+ e for e in entries
41
+ if tag_set.issubset({t.lower() for t in e.tags})
42
+ ]
43
+
44
+
45
+ def all_tags(entries: list[Entry]) -> list[str]:
46
+ """Collect all unique tags across entries, sorted."""
47
+ tags: set[str] = set()
48
+ for e in entries:
49
+ tags.update(e.tags)
50
+ return sorted(tags)
File without changes
@@ -0,0 +1,40 @@
1
+ @article{smith2020neural,
2
+ title = {Neural Networks for Reference Management},
3
+ author = {Smith, John and Doe, Jane},
4
+ year = {2020},
5
+ journal = {Journal of Information Science},
6
+ doi = {10.1234/jis.2020.001},
7
+ keywords = {machine-learning, nlp, references},
8
+ }
9
+
10
+ @book{knuth1997art,
11
+ title = {The Art of Computer Programming},
12
+ author = {Knuth, Donald E.},
13
+ year = {1997},
14
+ publisher = {Addison-Wesley},
15
+ keywords = {algorithms, programming, classics},
16
+ }
17
+
18
+ @inproceedings{vaswani2017attention,
19
+ title = {Attention Is All You Need},
20
+ author = {Vaswani, Ashish and Shazeer, Noam and Parmar, Niki},
21
+ year = {2017},
22
+ booktitle = {Advances in Neural Information Processing Systems},
23
+ keywords = {transformers, deep-learning, nlp},
24
+ }
25
+
26
+ @misc{tarkovsky1979stalker,
27
+ title = {Stalker},
28
+ author = {Tarkovsky, Andrei},
29
+ year = {1979},
30
+ note = {Film},
31
+ keywords = {film, sci-fi, soviet},
32
+ }
33
+
34
+ @misc{radiohead1997ok,
35
+ title = {OK Computer},
36
+ author = {Radiohead},
37
+ year = {1997},
38
+ note = {Album},
39
+ keywords = {music, rock, electronic},
40
+ }
@@ -0,0 +1,116 @@
1
+ """Tests for core BibTeX parsing and library management."""
2
+
3
+ from pathlib import Path
4
+
5
+ from antilibrary.core import Library, make_entry
6
+ from antilibrary.search import fuzzy_search, filter_by_tags, all_tags
7
+ from antilibrary.formatters import format_citation
8
+
9
+ FIXTURES = Path(__file__).parent / "fixtures"
10
+ SAMPLE_BIB = FIXTURES / "sample.bib"
11
+
12
+
13
+ class TestLibrary:
14
+ def test_parse_sample(self):
15
+ lib = Library(SAMPLE_BIB)
16
+ entries = lib.entries()
17
+ assert len(entries) == 5
18
+
19
+ def test_find_by_key(self):
20
+ lib = Library(SAMPLE_BIB)
21
+ entry = lib.find("knuth1997art")
22
+ assert entry is not None
23
+ assert entry.fields["title"] == "The Art of Computer Programming"
24
+
25
+ def test_find_missing(self):
26
+ lib = Library(SAMPLE_BIB)
27
+ assert lib.find("nonexistent") is None
28
+
29
+ def test_display_string(self):
30
+ lib = Library(SAMPLE_BIB)
31
+ entry = lib.find("knuth1997art")
32
+ assert "Knuth" in entry.display_string()
33
+ assert "1997" in entry.display_string()
34
+
35
+ def test_tags(self):
36
+ lib = Library(SAMPLE_BIB)
37
+ entry = lib.find("vaswani2017attention")
38
+ assert "transformers" in entry.tags
39
+ assert "deep-learning" in entry.tags
40
+
41
+ def test_add_entry(self, tmp_path):
42
+ bib = tmp_path / "test.bib"
43
+ bib.touch()
44
+ lib = Library(bib)
45
+ entry = make_entry(title="Test Paper", author="Author, A.", year="2024")
46
+ lib.add(entry)
47
+ lib2 = Library(bib)
48
+ assert lib2.count == 1
49
+ assert lib2.entries()[0].fields["title"] == "Test Paper"
50
+
51
+ def test_remove_entry(self, tmp_path):
52
+ bib = tmp_path / "test.bib"
53
+ bib.touch()
54
+ lib = Library(bib)
55
+ e1 = make_entry(title="Paper One", author="A", year="2024", key="one2024")
56
+ e2 = make_entry(title="Paper Two", author="B", year="2024", key="two2024")
57
+ lib.add(e1)
58
+ lib.add(e2)
59
+ assert lib.count == 2
60
+ assert lib.remove("one2024")
61
+ lib2 = Library(bib)
62
+ assert lib2.count == 1
63
+ assert lib2.entries()[0].key == "two2024"
64
+
65
+ def test_to_bibtex_roundtrip(self):
66
+ entry = make_entry(title="Roundtrip", author="Test, A.", year="2024", key="test2024")
67
+ bib_str = entry.to_bibtex()
68
+ assert "@misc{test2024," in bib_str
69
+ assert "Roundtrip" in bib_str
70
+
71
+
72
+ class TestSearch:
73
+ def test_fuzzy_search(self):
74
+ lib = Library(SAMPLE_BIB)
75
+ results = fuzzy_search(lib.entries(), "attention")
76
+ assert len(results) > 0
77
+ assert results[0].key == "vaswani2017attention"
78
+
79
+ def test_fuzzy_search_partial(self):
80
+ lib = Library(SAMPLE_BIB)
81
+ results = fuzzy_search(lib.entries(), "knuth art prog")
82
+ assert any(e.key == "knuth1997art" for e in results)
83
+
84
+ def test_filter_by_tags(self):
85
+ lib = Library(SAMPLE_BIB)
86
+ results = filter_by_tags(lib.entries(), ["nlp"])
87
+ assert len(results) == 2
88
+
89
+ def test_all_tags(self):
90
+ lib = Library(SAMPLE_BIB)
91
+ tags = all_tags(lib.entries())
92
+ assert "transformers" in tags
93
+ assert "film" in tags
94
+
95
+ def test_empty_query_returns_all(self):
96
+ lib = Library(SAMPLE_BIB)
97
+ results = fuzzy_search(lib.entries(), "")
98
+ assert len(results) == 5
99
+
100
+
101
+ class TestFormatters:
102
+ def test_bibtex_format(self):
103
+ entry = make_entry(title="T", key="k2024")
104
+ assert format_citation(entry, "bibtex") == "k2024"
105
+
106
+ def test_latex_format(self):
107
+ entry = make_entry(title="T", key="k2024")
108
+ assert format_citation(entry, "latex") == "\\cite{k2024}"
109
+
110
+ def test_org_cite_format(self):
111
+ entry = make_entry(title="T", key="k2024")
112
+ assert format_citation(entry, "org-cite") == "[cite:@k2024]"
113
+
114
+ def test_pandoc_format(self):
115
+ entry = make_entry(title="T", key="k2024")
116
+ assert format_citation(entry, "pandoc") == "[@k2024]"
@@ -0,0 +1,246 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.11"
4
+
5
+ [[package]]
6
+ name = "antilibrary"
7
+ version = "0.1.0"
8
+ source = { editable = "." }
9
+ dependencies = [
10
+ { name = "bibtexparser" },
11
+ { name = "rapidfuzz" },
12
+ { name = "rich" },
13
+ ]
14
+
15
+ [package.optional-dependencies]
16
+ dev = [
17
+ { name = "pytest" },
18
+ { name = "ruff" },
19
+ ]
20
+
21
+ [package.metadata]
22
+ requires-dist = [
23
+ { name = "bibtexparser", specifier = ">=2.0.0b7" },
24
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
25
+ { name = "rapidfuzz", specifier = ">=3.0" },
26
+ { name = "rich", specifier = ">=13.0" },
27
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" },
28
+ ]
29
+ provides-extras = ["dev"]
30
+
31
+ [[package]]
32
+ name = "bibtexparser"
33
+ version = "2.0.0b9"
34
+ source = { registry = "https://pypi.org/simple" }
35
+ dependencies = [
36
+ { name = "pylatexenc" },
37
+ ]
38
+ sdist = { url = "https://files.pythonhosted.org/packages/f4/37/e21f79f5116ad1fb92a8eb0e8a005cad59eb24660003982840daa6b9fafd/bibtexparser-2.0.0b9.tar.gz", hash = "sha256:bcddf796cfe321bb92c17c844ca65d81731fb753a1dd0e82bc76a9947aa0f360", size = 40435, upload-time = "2026-01-29T19:47:52.605Z" }
39
+ wheels = [
40
+ { url = "https://files.pythonhosted.org/packages/60/8e/5a3726bd421d4977262cee94a3660da4b9ca94397f951499c7d31ec0e863/bibtexparser-2.0.0b9-py3-none-any.whl", hash = "sha256:c30c73a9b4e2e8a4669c8c4d726e2d285990553f60612d8c952786af3cf766ca", size = 41407, upload-time = "2026-01-29T19:47:51.608Z" },
41
+ ]
42
+
43
+ [[package]]
44
+ name = "colorama"
45
+ version = "0.4.6"
46
+ source = { registry = "https://pypi.org/simple" }
47
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
48
+ wheels = [
49
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
50
+ ]
51
+
52
+ [[package]]
53
+ name = "iniconfig"
54
+ version = "2.3.0"
55
+ source = { registry = "https://pypi.org/simple" }
56
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
57
+ wheels = [
58
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
59
+ ]
60
+
61
+ [[package]]
62
+ name = "markdown-it-py"
63
+ version = "4.0.0"
64
+ source = { registry = "https://pypi.org/simple" }
65
+ dependencies = [
66
+ { name = "mdurl" },
67
+ ]
68
+ sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
69
+ wheels = [
70
+ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
71
+ ]
72
+
73
+ [[package]]
74
+ name = "mdurl"
75
+ version = "0.1.2"
76
+ source = { registry = "https://pypi.org/simple" }
77
+ sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
78
+ wheels = [
79
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
80
+ ]
81
+
82
+ [[package]]
83
+ name = "packaging"
84
+ version = "26.2"
85
+ source = { registry = "https://pypi.org/simple" }
86
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
87
+ wheels = [
88
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
89
+ ]
90
+
91
+ [[package]]
92
+ name = "pluggy"
93
+ version = "1.6.0"
94
+ source = { registry = "https://pypi.org/simple" }
95
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
96
+ wheels = [
97
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
98
+ ]
99
+
100
+ [[package]]
101
+ name = "pygments"
102
+ version = "2.20.0"
103
+ source = { registry = "https://pypi.org/simple" }
104
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
105
+ wheels = [
106
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
107
+ ]
108
+
109
+ [[package]]
110
+ name = "pylatexenc"
111
+ version = "2.10"
112
+ source = { registry = "https://pypi.org/simple" }
113
+ sdist = { url = "https://files.pythonhosted.org/packages/5d/ab/34ec41718af73c00119d0351b7a2531d2ebddb51833a36448fc7b862be60/pylatexenc-2.10.tar.gz", hash = "sha256:3dd8fd84eb46dc30bee1e23eaab8d8fb5a7f507347b23e5f38ad9675c84f40d3", size = 162597, upload-time = "2021-04-06T07:56:07.854Z" }
114
+
115
+ [[package]]
116
+ name = "pytest"
117
+ version = "9.0.3"
118
+ source = { registry = "https://pypi.org/simple" }
119
+ dependencies = [
120
+ { name = "colorama", marker = "sys_platform == 'win32'" },
121
+ { name = "iniconfig" },
122
+ { name = "packaging" },
123
+ { name = "pluggy" },
124
+ { name = "pygments" },
125
+ ]
126
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
127
+ wheels = [
128
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
129
+ ]
130
+
131
+ [[package]]
132
+ name = "rapidfuzz"
133
+ version = "3.14.5"
134
+ source = { registry = "https://pypi.org/simple" }
135
+ sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" }
136
+ wheels = [
137
+ { url = "https://files.pythonhosted.org/packages/e1/f9/3c41a7be8855803f4f6c713b472226a98d31d41869d98f64f4ca790510d6/rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4", size = 1952372, upload-time = "2026-04-07T11:13:58.32Z" },
138
+ { url = "https://files.pythonhosted.org/packages/9e/89/c2557e37531d03465193bff0ab9de70b468420a807d71a26a65100635459/rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab", size = 1159782, upload-time = "2026-04-07T11:14:00.127Z" },
139
+ { url = "https://files.pythonhosted.org/packages/1a/b2/ffeeb7eca1a897d51b998f4c0ef0281696c3b06abcca4f88f9def708ffe1/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358", size = 1383677, upload-time = "2026-04-07T11:14:01.696Z" },
140
+ { url = "https://files.pythonhosted.org/packages/6b/d0/4539e42a2d596e068f7738f279638a4a74edd1fbb6f8594e2458058979c6/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925", size = 3168906, upload-time = "2026-04-07T11:14:03.29Z" },
141
+ { url = "https://files.pythonhosted.org/packages/5e/1c/3ec897eb9d8b05308aa8ef6ae4ed64b088ad521a3f9d8ff469e7e97bc2b0/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8", size = 1478176, upload-time = "2026-04-07T11:14:04.94Z" },
142
+ { url = "https://files.pythonhosted.org/packages/ab/ba/970c03a12ce20a5399e22afe9f8932fd4cd1265b8a8461d0e63b00eb4eae/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13", size = 2402441, upload-time = "2026-04-07T11:14:07.228Z" },
143
+ { url = "https://files.pythonhosted.org/packages/81/93/61d351cae60c1d0e21ba5ff1a1015ad045539ed215da9d6e302204ed887a/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489", size = 2511628, upload-time = "2026-04-07T11:14:09.234Z" },
144
+ { url = "https://files.pythonhosted.org/packages/87/52/374d2d4f60fd98155142a869323aa221e30868cfa1f15171a0f64070c247/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6", size = 4275480, upload-time = "2026-04-07T11:14:11.332Z" },
145
+ { url = "https://files.pythonhosted.org/packages/d8/04/82e7989bc9ec20a15b720a335c5cb6b0724bf6582013898f90a3280cfccd/rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f", size = 1725627, upload-time = "2026-04-07T11:14:13.217Z" },
146
+ { url = "https://files.pythonhosted.org/packages/b9/b5/eca8ac5609bc9bcb02bb6ff87fa5983cc92b8772d66a431556ab8a8c178f/rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819", size = 1545977, upload-time = "2026-04-07T11:14:14.766Z" },
147
+ { url = "https://files.pythonhosted.org/packages/ca/e1/dbf318de28f65fa2cdd0a9dfbdee380f8199eb83b19259bc4f8592551b4e/rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb", size = 816827, upload-time = "2026-04-07T11:14:16.788Z" },
148
+ { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" },
149
+ { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" },
150
+ { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" },
151
+ { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" },
152
+ { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" },
153
+ { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" },
154
+ { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" },
155
+ { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" },
156
+ { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" },
157
+ { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" },
158
+ { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" },
159
+ { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" },
160
+ { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" },
161
+ { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" },
162
+ { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" },
163
+ { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" },
164
+ { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" },
165
+ { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" },
166
+ { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" },
167
+ { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" },
168
+ { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" },
169
+ { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" },
170
+ { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" },
171
+ { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" },
172
+ { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" },
173
+ { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" },
174
+ { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" },
175
+ { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" },
176
+ { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" },
177
+ { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" },
178
+ { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" },
179
+ { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" },
180
+ { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" },
181
+ { url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" },
182
+ { url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" },
183
+ { url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" },
184
+ { url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" },
185
+ { url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" },
186
+ { url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" },
187
+ { url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" },
188
+ { url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" },
189
+ { url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" },
190
+ { url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" },
191
+ { url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" },
192
+ { url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" },
193
+ { url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" },
194
+ { url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" },
195
+ { url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" },
196
+ { url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" },
197
+ { url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" },
198
+ { url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" },
199
+ { url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" },
200
+ { url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" },
201
+ { url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" },
202
+ { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" },
203
+ { url = "https://files.pythonhosted.org/packages/d9/ee/e71853bf82846c5c2174b924b71d8e8099fb05ff87c958a720380b434ba3/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c", size = 1888603, upload-time = "2026-04-07T11:16:18.223Z" },
204
+ { url = "https://files.pythonhosted.org/packages/36/82/40f67b730f32be2ebad9f62add1571c754f52249254b2e88af094b907eee/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d", size = 1120599, upload-time = "2026-04-07T11:16:20.682Z" },
205
+ { url = "https://files.pythonhosted.org/packages/ef/9f/a3635cc4ec8fc6e14b46e7db1f7f8763d8c4bef33dcc124eea2e6cb2c8f3/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53", size = 1348524, upload-time = "2026-04-07T11:16:23.451Z" },
206
+ { url = "https://files.pythonhosted.org/packages/cc/1b/2b229520f0b48464cfcd7aa758f74551d12c9bc4ab544022a60210aab064/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5", size = 3099302, upload-time = "2026-04-07T11:16:25.858Z" },
207
+ { url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" },
208
+ ]
209
+
210
+ [[package]]
211
+ name = "rich"
212
+ version = "15.0.0"
213
+ source = { registry = "https://pypi.org/simple" }
214
+ dependencies = [
215
+ { name = "markdown-it-py" },
216
+ { name = "pygments" },
217
+ ]
218
+ sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
219
+ wheels = [
220
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
221
+ ]
222
+
223
+ [[package]]
224
+ name = "ruff"
225
+ version = "0.15.12"
226
+ source = { registry = "https://pypi.org/simple" }
227
+ sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
228
+ wheels = [
229
+ { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
230
+ { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
231
+ { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
232
+ { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
233
+ { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
234
+ { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
235
+ { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
236
+ { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
237
+ { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
238
+ { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
239
+ { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
240
+ { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
241
+ { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
242
+ { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
243
+ { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
244
+ { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
245
+ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
246
+ ]