fromscratch-pysearch 0.3.0__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.
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.5
2
+ Name: fromscratch-pysearch
3
+ Version: 0.3.0
4
+ Summary: An educational full-text search engine built from scratch in Python
5
+ Project-URL: Homepage, https://github.com/robcox2910/pysearch
6
+ Project-URL: Repository, https://github.com/robcox2910/pysearch
7
+ Project-URL: Issues, https://github.com/robcox2910/pysearch/issues
8
+ Project-URL: Documentation, https://robcox2910.github.io/pysearch/
9
+ Author: Rob Cox
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: education,inverted-index,search,tfidf
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Education
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Education
19
+ Classifier: Topic :: Text Processing :: Indexing
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.14
22
+ Provides-Extra: dev
23
+ Requires-Dist: commitizen>=4.4.0; extra == 'dev'
24
+ Requires-Dist: hypothesis>=6.170; extra == 'dev'
25
+ Requires-Dist: pip-audit>=2.10.0; extra == 'dev'
26
+ Requires-Dist: pre-commit>=4.0.1; extra == 'dev'
27
+ Requires-Dist: pyright>=1.1.400; extra == 'dev'
28
+ Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
29
+ Requires-Dist: pytest>=8.3.4; extra == 'dev'
30
+ Requires-Dist: ruff>=0.9.3; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # PySearch
34
+
35
+ **An educational full-text search engine built from scratch in Python.**
36
+
37
+ ## The Librarian Who Read Every Book
38
+
39
+ Imagine a librarian who has read every single book in the library. When you ask
40
+ her "Which books mention *dinosaurs*?", she doesn't need to open a single book.
41
+ She already knows the answer because she kept notes while reading.
42
+
43
+ For every word she encountered, she wrote down which book it was in and how many
44
+ times it appeared. Her notebook is called an **inverted index**, and it lets her
45
+ answer your question in the blink of an eye.
46
+
47
+ But what if ten books mention dinosaurs? Which one should you read first? The
48
+ librarian uses a clever trick called **TF-IDF** -- she ranks the books so the
49
+ ones that talk about dinosaurs *the most* (and about other things *the least*)
50
+ float to the top.
51
+
52
+ PySearch teaches you how to build this librarian from scratch.
53
+
54
+ ## Examples
55
+
56
+ ### Adding Documents and Searching
57
+
58
+ ```python
59
+ from pysearch.engine import SearchEngine
60
+
61
+ # Create a search engine and add some documents
62
+ engine = SearchEngine()
63
+ engine.add("doc1", "Python is a great programming language")
64
+ engine.add("doc2", "Java is also a popular language")
65
+ engine.add("doc3", "Python and Java are both used for web development")
66
+
67
+ # Search for a single term -- results ranked by relevance
68
+ results = engine.search("python")
69
+ for doc_id, score in results:
70
+ print(f"{doc_id}: {score:.4f}")
71
+
72
+ # Boolean queries
73
+ results = engine.search("python AND java") # documents with both
74
+ results = engine.search("python OR java") # documents with either
75
+ ```
76
+
77
+ ### Using the Tokenizer Directly
78
+
79
+ ```python
80
+ from pysearch.tokenizer import tokenize, simple_stem
81
+
82
+ tokens = tokenize("The quick brown foxes are running!")
83
+ print(tokens) # ['quick', 'brown', 'fox', 'runn']
84
+
85
+ print(simple_stem("running")) # 'runn'
86
+ print(simple_stem("played")) # 'play'
87
+ print(simple_stem("quickly")) # 'quick'
88
+ ```
89
+
90
+ ### Building an Inverted Index
91
+
92
+ ```python
93
+ from pysearch.index import InvertedIndex
94
+
95
+ index = InvertedIndex()
96
+ index.add_document("doc1", "cats and dogs")
97
+ index.add_document("doc2", "cats and birds")
98
+ index.add_document("doc3", "dogs and birds")
99
+
100
+ print(index.search("cat")) # ['doc1', 'doc2']
101
+ print(index.search_and(["cat", "dog"])) # ['doc1']
102
+ print(index.search_or(["cat", "dog"])) # ['doc1', 'doc2', 'doc3']
103
+ ```
104
+
105
+ ## Features
106
+
107
+ - **Tokenizer** -- Split text into searchable words, remove stop words, and
108
+ apply simple stemming
109
+ - **Inverted Index** -- Map every word to the documents that contain it for
110
+ instant lookups
111
+ - **TF-IDF Ranking** -- Score and rank results so the most relevant documents
112
+ come first
113
+ - **Boolean Queries** -- Combine search terms with AND and OR operators
114
+ - **100% Typed** -- Full type annotations with strict Pyright checking
115
+
116
+ ## Quick Start
117
+
118
+ ```bash
119
+ # Install from source
120
+ git clone https://github.com/robcox2910/pysearch.git
121
+ cd pysearch
122
+ uv sync --all-extras
123
+
124
+ # Run the tests
125
+ uv run pytest
126
+
127
+ # Try it in a Python shell
128
+ uv run python -c "
129
+ from pysearch.engine import SearchEngine
130
+ engine = SearchEngine()
131
+ engine.add('doc1', 'hello world')
132
+ print(engine.search('hello'))
133
+ "
134
+ ```
135
+
136
+ ## Concepts
137
+
138
+ Saving an engine to disk and loading it back: [Saving Your Index](docs/concepts/saving.md).
139
+
140
+ <!-- docs:start -->
141
+ ## Documentation
142
+
143
+ Full docs (with kid-friendly explanations of every idea): https://robcox2910.github.io/pysearch/
144
+ <!-- docs:end -->
145
+
146
+ ## Releasing
147
+
148
+ Other projects in the series ([PyStack](https://github.com/robcox2910/pystack)) use a pinned release of this one,
149
+ not whatever is on `main`. To publish a change for them:
150
+
151
+ 1. Merge the change to `main`.
152
+ 2. Tag it: `git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z`.
153
+ A change to the public API bumps the minor version (`v0.1.0` → `v0.2.0`);
154
+ anything else bumps the patch (`v0.1.0` → `v0.1.1`).
155
+ 3. In each project that uses it, edit the tag in `[tool.uv.sources]`, run
156
+ `uv lock --upgrade-package <name>`, and open a pull request so CI checks
157
+ the pair together.
158
+
159
+ <!-- series:start -->
160
+ ## Related Projects
161
+
162
+ This project is one of a series that builds real systems "from scratch in Python" to teach how they work. See the [series home](https://robcox2910.github.io/) for the full story.
163
+
164
+ | Project | What it teaches | Repository | Docs |
165
+ | --- | --- | --- | --- |
166
+ | PyOS | Operating systems | [py-os](https://github.com/robcox2910/py-os) | [Docs](https://robcox2910.github.io/py-os/) |
167
+ | Pebble | Compilers and programming languages | [pebble-lang](https://github.com/robcox2910/pebble-lang) | [Docs](https://robcox2910.github.io/pebble-lang/) |
168
+ | PyDB | Relational databases | [pydb](https://github.com/robcox2910/pydb) | [Docs](https://robcox2910.github.io/pydb/) |
169
+ | PyKV | Key-value stores | [pykv](https://github.com/robcox2910/pykv) | [Docs](https://robcox2910.github.io/pykv/) |
170
+ | PyDocDB | Document databases | [pydoc-db](https://github.com/robcox2910/pydoc-db) | [Docs](https://robcox2910.github.io/pydoc-db/) |
171
+ | PyGraphDB | Graph databases | [pygraphdb](https://github.com/robcox2910/pygraphdb) | [Docs](https://robcox2910.github.io/pygraphdb/) |
172
+ | PyTSDB | Time-series databases | [pytsdb](https://github.com/robcox2910/pytsdb) | [Docs](https://robcox2910.github.io/pytsdb/) |
173
+ | PyVecDB | Vector databases | [pyvecdb](https://github.com/robcox2910/pyvecdb) | [Docs](https://robcox2910.github.io/pyvecdb/) |
174
+ | PyColDB | Column-family databases | [pycoldb](https://github.com/robcox2910/pycoldb) | [Docs](https://robcox2910.github.io/pycoldb/) |
175
+ | PyCrypt | Cryptography | [pycrypt](https://github.com/robcox2910/pycrypt) | [Docs](https://robcox2910.github.io/pycrypt/) |
176
+ | PyNet | Computer networking | [pynet](https://github.com/robcox2910/pynet) | [Docs](https://robcox2910.github.io/pynet/) |
177
+ | PyWeb | HTTP web servers | [pyweb](https://github.com/robcox2910/pyweb) | [Docs](https://robcox2910.github.io/pyweb/) |
178
+ | PyGit | Version control | [pygit](https://github.com/robcox2910/pygit) | [Docs](https://robcox2910.github.io/pygit/) |
179
+ | **PySearch** | **Search engines** | [pysearch](https://github.com/robcox2910/pysearch) | [Docs](https://robcox2910.github.io/pysearch/) |
180
+ | PyMQ | Message queues | [pymq](https://github.com/robcox2910/pymq) | [Docs](https://robcox2910.github.io/pymq/) |
181
+ | PyLLM | Language models (the brain) | [pyllm](https://github.com/robcox2910/pyllm) | [Docs](https://robcox2910.github.io/pyllm/) |
182
+ | PyScheduler | Job scheduling | [pyscheduler](https://github.com/robcox2910/pyscheduler) | [Docs](https://robcox2910.github.io/pyscheduler/) |
183
+ | PyStack | Full-stack integration | [pystack](https://github.com/robcox2910/pystack) | [Docs](https://robcox2910.github.io/pystack/) |
184
+ <!-- series:end -->
185
+
186
+ ## License
187
+
188
+ MIT -- see [LICENSE](LICENSE) for details.
@@ -0,0 +1,15 @@
1
+ pysearch/__init__.py,sha256=itjA-esHRCj8DdeWHzJWRv6kob7yqJiVnZEbEHqbuC0,84
2
+ pysearch/_version.py,sha256=RCY04NY0kBCyn6ttH6lnVL6Gz2gceOrt45f0yDOOU8A,520
3
+ pysearch/atomic.py,sha256=QLLPGZqpA-TgVV5lDEKCLB7vVr05bxj97_Fwykz_gi4,2345
4
+ pysearch/engine.py,sha256=FiGX5g8HoSag2WnEYEABV2KfOuS5b4qnalBuparjRco,5914
5
+ pysearch/errors.py,sha256=H5xkapTBr1ww7UqMKhUUfIJe5R0kp5xT_DBESkCNf4Y,607
6
+ pysearch/index.py,sha256=cdHZP4dhaRNJ7E7dI-3xNnqxzHtAWYJVNxRvgQr6w5Q,4107
7
+ pysearch/persistence.py,sha256=yCs5K-ZTV7MJ74Qhz6AQak51yOZEYS2XS1F7KZEokxw,2265
8
+ pysearch/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ pysearch/query.py,sha256=zkPpgLt9ikC1tsBddv_y1BWsaagp9-4k710Cb1ldfMs,3658
10
+ pysearch/tfidf.py,sha256=y4K5sG5GO-YQG0qIM6uqnvoJ0_OUuBMM7tAFBOVAUi4,3114
11
+ pysearch/tokenizer.py,sha256=Iq-W7g0sLHZaKAGwpqdBhzeI5oBWUOfj5aG_UYZjRCg,2686
12
+ fromscratch_pysearch-0.3.0.dist-info/METADATA,sha256=4BssHRVeJWfC7XEyhmtoM301Vu34zxa6ejwoUOiqu1Q,7998
13
+ fromscratch_pysearch-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
+ fromscratch_pysearch-0.3.0.dist-info/licenses/LICENSE,sha256=NtHM2ak--oE71VCcY50kcCoC1cKS0LRUsxq_xA89RNc,1064
15
+ fromscratch_pysearch-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rob Cox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pysearch/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """PySearch -- educational full-text search engine built from scratch in Python."""
pysearch/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.3.0'
22
+ __version_tuple__ = version_tuple = (0, 3, 0)
23
+
24
+ __commit_id__ = commit_id = None
pysearch/atomic.py ADDED
@@ -0,0 +1,66 @@
1
+ """Safe saving -- never leave a half-written file behind.
2
+
3
+ The usual way to save a file is: open it, wipe it, write the new
4
+ contents. If the computer crashes after the wipe but before the writing
5
+ finishes, your data is gone -- the old version was wiped and the new one
6
+ never arrived.
7
+
8
+ So we save like a careful student copying out their homework:
9
+
10
+ 1. Write the new version on a **fresh sheet** (a temporary file sitting
11
+ right next to the real one).
12
+ 2. Make sure the **ink is dry** -- ``fsync`` forces the bytes out of the
13
+ computer's memory and onto the disk.
14
+ 3. **Swap** the fresh sheet in for the old one in a single instant step
15
+ (``Path.replace``). There is never a moment with no complete file: you
16
+ see either the old version or the new one, never half of each.
17
+
18
+ Real databases (SQLite, PostgreSQL, Redis) all use this same trick.
19
+ """
20
+
21
+ import contextlib
22
+ import os
23
+ import stat
24
+ import tempfile
25
+ from pathlib import Path
26
+
27
+ # Permissions for a brand-new file: owner can read/write, others can read.
28
+ _NEW_FILE_MODE = 0o644
29
+
30
+
31
+ def atomic_write_text(path: Path, text: str) -> None:
32
+ """Replace the file at *path* with *text*, so a crash can never leave it half-written.
33
+
34
+ Args:
35
+ path: The file to create or replace (its folder must already exist).
36
+ text: The new contents, saved as UTF-8.
37
+
38
+ """
39
+ mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else _NEW_FILE_MODE
40
+ fd, temp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
41
+ temp_path = Path(temp_name)
42
+ try:
43
+ with os.fdopen(fd, "w", encoding="utf-8") as temp_file:
44
+ temp_file.write(text)
45
+ temp_file.flush()
46
+ os.fsync(temp_file.fileno())
47
+ temp_path.chmod(mode)
48
+ temp_path.replace(path)
49
+ except BaseException:
50
+ temp_path.unlink(missing_ok=True) # don't leave the scrap sheet lying around
51
+ raise
52
+ _sync_folder(path.parent)
53
+
54
+
55
+ def _sync_folder(folder: Path) -> None:
56
+ """Make the swap itself durable by syncing the folder's list of files.
57
+
58
+ Some systems (like Windows) can't sync a folder; there the swap is
59
+ still safe, just not guaranteed to survive a power cut.
60
+ """
61
+ with contextlib.suppress(OSError):
62
+ fd = os.open(folder, os.O_RDONLY)
63
+ try:
64
+ os.fsync(fd)
65
+ finally:
66
+ os.close(fd)
pysearch/engine.py ADDED
@@ -0,0 +1,162 @@
1
+ """Tie every component together into a complete search engine.
2
+
3
+ The search engine is the librarian herself. She knows how to file new books
4
+ (add documents), understand your question (parse queries), look things up
5
+ (search the index), and tell you which results matter most (rank by TF-IDF).
6
+ """
7
+
8
+ from typing import Self, cast
9
+
10
+ from pysearch.index import InvertedIndex
11
+ from pysearch.query import AndQuery, SearchQuery, TermQuery, parse_query
12
+ from pysearch.tfidf import rank_results
13
+
14
+ # Bump this if the saved layout ever changes, so old files are refused, not misread.
15
+ SAVE_FORMAT = 1
16
+
17
+
18
+ class SearchEngine:
19
+ """A full-text search engine with TF-IDF ranking and boolean queries."""
20
+
21
+ def __init__(self) -> None:
22
+ """Initialise an empty search engine."""
23
+ self._index = InvertedIndex()
24
+ # The original text of every book, in the order it arrived, so the
25
+ # engine can be saved and rebuilt exactly.
26
+ self._documents: dict[str, str] = {}
27
+
28
+ def add(self, doc_id: str, text: str) -> None:
29
+ """Hand the librarian a new book to file away.
30
+
31
+ Args:
32
+ doc_id: A unique identifier for the document.
33
+ text: The raw document text.
34
+
35
+ """
36
+ self._index.add_document(doc_id, text)
37
+ self._documents[doc_id] = text
38
+
39
+ def to_dict(self) -> dict[str, object]:
40
+ """Return everything needed to rebuild this engine, as plain data.
41
+
42
+ Returns:
43
+ ``{"format": 1, "documents": {doc_id: text, ...}}``
44
+
45
+ """
46
+ return {"format": SAVE_FORMAT, "documents": dict(self._documents)}
47
+
48
+ @classmethod
49
+ def from_dict(cls, data: dict[str, object]) -> Self:
50
+ """Rebuild an engine from :meth:`to_dict` data by re-filing every book.
51
+
52
+ Args:
53
+ data: The saved data.
54
+
55
+ Returns:
56
+ A new engine holding the same documents.
57
+
58
+ Raises:
59
+ ValueError: If *data* is from an unknown format version.
60
+ TypeError: If *data* doesn't have the expected shape.
61
+
62
+ """
63
+ version = data.get("format")
64
+ # ``True == 1`` and ``1.0 == 1`` in Python, so insist on a real int.
65
+ if type(version) is not int or version != SAVE_FORMAT:
66
+ msg = f"unknown save format {version!r} (expected the integer {SAVE_FORMAT})"
67
+ raise ValueError(msg)
68
+ documents = data["documents"]
69
+ if not isinstance(documents, dict):
70
+ msg = "'documents' must be an object mapping ids to text"
71
+ raise TypeError(msg)
72
+ engine = cls()
73
+ for doc_id, text in cast("dict[object, object]", documents).items():
74
+ if not isinstance(doc_id, str) or not isinstance(text, str):
75
+ msg = "every document id and text must be a string"
76
+ raise TypeError(msg)
77
+ engine.add(doc_id, text)
78
+ return engine
79
+
80
+ def search(self, query_text: str) -> list[tuple[str, float]]:
81
+ """Ask the librarian a question and get back the best-matching books, best first.
82
+
83
+ Args:
84
+ query_text: A query string such as ``"cats"``, ``"cats AND dogs"``,
85
+ or ``"cats OR dogs"``.
86
+
87
+ Returns:
88
+ A list of ``(doc_id, score)`` tuples sorted by relevance.
89
+
90
+ """
91
+ query = parse_query(query_text)
92
+ doc_ids = self._execute(query)
93
+
94
+ if not doc_ids:
95
+ return []
96
+
97
+ # Rank by ALL terms in the query so every word counts.
98
+ all_terms = self._collect_terms(query)
99
+ return rank_results(all_terms, doc_ids, self._index)
100
+
101
+ @property
102
+ def document_count(self) -> int:
103
+ """Return the total number of indexed documents."""
104
+ return self._index.document_count
105
+
106
+ def _execute(self, query: SearchQuery) -> list[str]:
107
+ """Work out which documents match a parsed query tree.
108
+
109
+ A long question makes a tall tree (2,000 words, 2,000 levels), too
110
+ tall for a function that calls itself once per level -- Python stops
111
+ at about 1,000 calls deep. So instead of calling itself, this keeps
112
+ its own to-do pile: each branch is visited once on the way down
113
+ (to queue its two halves) and once on the way back up (to join
114
+ their answers).
115
+
116
+ Args:
117
+ query: A parsed ``SearchQuery`` tree.
118
+
119
+ Returns:
120
+ A list of matching document IDs.
121
+
122
+ """
123
+ to_do: list[tuple[SearchQuery, bool]] = [(query, False)]
124
+ answers: list[set[str]] = []
125
+ while to_do:
126
+ node, halves_done = to_do.pop()
127
+ if isinstance(node, TermQuery):
128
+ answers.append(set(self._index.search(node.term)))
129
+ elif not halves_done:
130
+ # Come back to this branch once both halves are answered.
131
+ to_do.extend([(node, True), (node.right, False), (node.left, False)])
132
+ else:
133
+ right = answers.pop()
134
+ left = answers.pop()
135
+ answers.append(left & right if isinstance(node, AndQuery) else left | right)
136
+ return list(answers[0])
137
+
138
+ @staticmethod
139
+ def _collect_terms(query: SearchQuery) -> list[str]:
140
+ """Collect every term from a query tree for ranking, left to right.
141
+
142
+ Walk the whole tree and gather every leaf -- like picking every
143
+ apple off a tree instead of just the first one you see. Like
144
+ :meth:`_execute`, it keeps its own to-do pile so a tall tree can't
145
+ make it crash.
146
+
147
+ Args:
148
+ query: A parsed ``SearchQuery`` tree.
149
+
150
+ Returns:
151
+ A list of all term strings in the query.
152
+
153
+ """
154
+ terms: list[str] = []
155
+ to_do: list[SearchQuery] = [query]
156
+ while to_do:
157
+ node = to_do.pop()
158
+ if isinstance(node, TermQuery):
159
+ terms.append(node.term)
160
+ else:
161
+ to_do.extend([node.right, node.left]) # left comes off the pile first
162
+ return terms
pysearch/errors.py ADDED
@@ -0,0 +1,22 @@
1
+ """Search engine error types."""
2
+
3
+
4
+ class PySearchError(Exception):
5
+ """Base exception for all PySearch errors."""
6
+
7
+
8
+ class QueryParseError(PySearchError):
9
+ """Raise when a search query cannot be parsed."""
10
+
11
+
12
+ class DuplicateDocumentError(PySearchError):
13
+ """Raise when a document id is added to the index a second time."""
14
+
15
+
16
+ class PersistenceError(PySearchError):
17
+ """Raise when a saved search engine can't be read back.
18
+
19
+ Like opening the librarian's notebook and finding the pages torn or
20
+ scribbled on -- the file is missing, isn't valid JSON, or isn't a saved
21
+ engine at all.
22
+ """
pysearch/index.py ADDED
@@ -0,0 +1,127 @@
1
+ """Build and query an inverted index.
2
+
3
+ An inverted index is like the librarian's memory. Instead of reading every
4
+ book to find a word, the librarian keeps a notebook: for each word, she writes
5
+ down which books contain it and how many times it appears. When you ask her
6
+ about a word, she just flips to that page in her notebook -- instant answer!
7
+ """
8
+
9
+ from collections import defaultdict
10
+
11
+ from pysearch.errors import DuplicateDocumentError
12
+ from pysearch.tokenizer import simple_stem, tokenize
13
+
14
+
15
+ class InvertedIndex:
16
+ """Map terms to the documents that contain them.
17
+
18
+ The index stores two things for every (term, document) pair:
19
+ - which documents contain the term (for searching)
20
+ - how many times the term appears in each document (for ranking)
21
+ """
22
+
23
+ def __init__(self) -> None:
24
+ """Initialise an empty inverted index."""
25
+ self._index: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
26
+ self._doc_ids: set[str] = set()
27
+
28
+ def add_document(self, doc_id: str, text: str) -> None:
29
+ """File a new book away -- read its words and note which ones live in this document.
30
+
31
+ Args:
32
+ doc_id: A unique identifier for the document.
33
+ text: The raw document text.
34
+
35
+ Raises:
36
+ DuplicateDocumentError: If a document with this id was already
37
+ added -- filing the same book twice would make every word in
38
+ it look more common than it really is.
39
+
40
+ """
41
+ if doc_id in self._doc_ids:
42
+ msg = f"Document {doc_id!r} is already in the index"
43
+ raise DuplicateDocumentError(msg)
44
+ self._doc_ids.add(doc_id)
45
+ tokens = tokenize(text)
46
+ for token in tokens:
47
+ self._index[token][doc_id] += 1
48
+
49
+ def search(self, term: str) -> list[str]:
50
+ """Ask the librarian which books contain a word -- she flips to that page and reads the list.
51
+
52
+ Args:
53
+ term: A single search term (will be stemmed).
54
+
55
+ Returns:
56
+ A list of matching document IDs.
57
+
58
+ """
59
+ stemmed = simple_stem(term.lower())
60
+ return list(self._index.get(stemmed, {}))
61
+
62
+ def search_and(self, terms: list[str]) -> list[str]:
63
+ """Find books that mention EVERY word you asked for -- both "cats" and "dogs", not just one.
64
+
65
+ Args:
66
+ terms: A list of search terms.
67
+
68
+ Returns:
69
+ Document IDs present in every term's posting list (intersection).
70
+
71
+ """
72
+ if not terms:
73
+ return []
74
+ sets = [set(self.search(t)) for t in terms]
75
+ result = sets[0]
76
+ for s in sets[1:]:
77
+ result &= s
78
+ return list(result)
79
+
80
+ def search_or(self, terms: list[str]) -> list[str]:
81
+ """Find books that mention ANY of your words -- "cats" or "dogs" or both will do.
82
+
83
+ Args:
84
+ terms: A list of search terms.
85
+
86
+ Returns:
87
+ Document IDs present in at least one term's posting list (union).
88
+
89
+ """
90
+ if not terms:
91
+ return []
92
+ result: set[str] = set()
93
+ for t in terms:
94
+ result |= set(self.search(t))
95
+ return list(result)
96
+
97
+ @property
98
+ def document_count(self) -> int:
99
+ """Return the total number of indexed documents."""
100
+ return len(self._doc_ids)
101
+
102
+ def get_term_frequency(self, term: str, doc_id: str) -> int:
103
+ """Return how many times *term* appears in *doc_id*.
104
+
105
+ Args:
106
+ term: The search term (will be stemmed).
107
+ doc_id: The document identifier.
108
+
109
+ Returns:
110
+ The number of occurrences, or 0 if not found.
111
+
112
+ """
113
+ stemmed = simple_stem(term.lower())
114
+ return self._index.get(stemmed, {}).get(doc_id, 0)
115
+
116
+ def get_document_frequency(self, term: str) -> int:
117
+ """Return how many documents contain *term*.
118
+
119
+ Args:
120
+ term: The search term (will be stemmed).
121
+
122
+ Returns:
123
+ The number of documents containing the term.
124
+
125
+ """
126
+ stemmed = simple_stem(term.lower())
127
+ return len(self._index.get(stemmed, {}))
@@ -0,0 +1,65 @@
1
+ """Save a search engine to disk and load it back.
2
+
3
+ Closing the library for the night? The librarian writes every book's text
4
+ into a notebook (a JSON file). Tomorrow she reads the notebook and files
5
+ every book again, so every search gives the same answers as before.
6
+
7
+ Saving uses :func:`pysearch.atomic.atomic_write_text`, so a crash halfway
8
+ through can never leave a half-written notebook.
9
+ """
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import cast
14
+
15
+ from pysearch.atomic import atomic_write_text
16
+ from pysearch.engine import SearchEngine
17
+ from pysearch.errors import PersistenceError
18
+
19
+
20
+ def save_engine(engine: SearchEngine, path: str | Path) -> None:
21
+ """Save *engine* to a JSON file at *path*.
22
+
23
+ Args:
24
+ engine: The engine to save.
25
+ path: Where to write it (its folder must already exist).
26
+
27
+ """
28
+ atomic_write_text(Path(path), json.dumps(engine.to_dict(), indent=2))
29
+
30
+
31
+ def load_engine(path: str | Path) -> SearchEngine:
32
+ """Load an engine saved by :func:`save_engine`.
33
+
34
+ Args:
35
+ path: The file to read.
36
+
37
+ Returns:
38
+ A new engine holding the saved documents.
39
+
40
+ Raises:
41
+ PersistenceError: If the file is missing, unreadable, not text, not
42
+ valid JSON, or not a saved engine.
43
+
44
+ """
45
+ file_path = Path(path)
46
+ try:
47
+ raw = file_path.read_text(encoding="utf-8")
48
+ except (OSError, UnicodeDecodeError) as exc: # missing, unreadable, or not text at all
49
+ msg = f"Failed to load search engine from {file_path}: {exc}"
50
+ raise PersistenceError(msg) from exc
51
+ # Not JSON (JSONDecodeError is a kind of ValueError), a number too long
52
+ # for Python to read (ValueError), or lists nested too deep (RecursionError).
53
+ try:
54
+ data = json.loads(raw)
55
+ except (ValueError, RecursionError) as exc:
56
+ msg = f"Invalid JSON in {file_path}: {exc}"
57
+ raise PersistenceError(msg) from exc
58
+ if not isinstance(data, dict):
59
+ msg = f"{file_path} is not a saved search engine"
60
+ raise PersistenceError(msg)
61
+ try:
62
+ return SearchEngine.from_dict(cast("dict[str, object]", data))
63
+ except (KeyError, TypeError, ValueError) as exc:
64
+ msg = f"{file_path} is not a saved search engine: {exc}"
65
+ raise PersistenceError(msg) from exc
pysearch/py.typed ADDED
File without changes
pysearch/query.py ADDED
@@ -0,0 +1,118 @@
1
+ """Parse search queries into a structured representation.
2
+
3
+ Understanding your question is the first step to answering it. The query
4
+ parser reads what you typed -- like ``cats AND dogs`` -- and turns it into a
5
+ tree that the search engine can execute step by step.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+ from pysearch.errors import QueryParseError
11
+ from pysearch.tokenizer import split_words
12
+
13
+
14
+ @dataclass
15
+ class TermQuery:
16
+ """A query for a single search term."""
17
+
18
+ term: str
19
+
20
+
21
+ @dataclass
22
+ class AndQuery:
23
+ """A query requiring both the left and right sub-queries to match."""
24
+
25
+ left: TermQuery | AndQuery | OrQuery
26
+ right: TermQuery | AndQuery | OrQuery
27
+
28
+
29
+ @dataclass
30
+ class OrQuery:
31
+ """A query requiring either the left or right sub-query to match."""
32
+
33
+ left: TermQuery | AndQuery | OrQuery
34
+ right: TermQuery | AndQuery | OrQuery
35
+
36
+
37
+ SearchQuery = TermQuery | AndQuery | OrQuery
38
+
39
+
40
+ _OPERATORS = frozenset({"AND", "OR"})
41
+
42
+
43
+ def parse_query(text: str) -> SearchQuery:
44
+ """Read what you typed -- like "cats AND dogs" -- and turn it into a plan the engine can follow.
45
+
46
+ Supported formats:
47
+
48
+ - ``"word"`` -- a single term
49
+ - ``"a AND b"`` / ``"a OR b"`` -- both / either must match
50
+ - ``"a b"`` -- words next to each other must *all* match (an implied AND)
51
+ - longer chains like ``"a AND b OR c"`` are read left to right:
52
+ ``(a AND b) OR c``
53
+
54
+ Each word is cleaned up exactly like document text (see
55
+ :func:`~pysearch.tokenizer.split_words`): punctuation and stop words go,
56
+ and a word that splits into pieces (``"state-of-the-art"``) needs all of
57
+ its pieces.
58
+
59
+ Args:
60
+ text: The raw query string.
61
+
62
+ Returns:
63
+ A ``SearchQuery`` representing the parsed query.
64
+
65
+ Raises:
66
+ QueryParseError: If the query is empty, has nothing searchable, or
67
+ has an operator without a word on both sides.
68
+
69
+ """
70
+ words = text.split()
71
+ if not words:
72
+ msg = "Query must not be empty."
73
+ raise QueryParseError(msg)
74
+
75
+ result: SearchQuery | None = None
76
+ pending_operator = "AND"
77
+ expecting_term = True
78
+ for word in words:
79
+ operator = word.upper()
80
+ if operator in _OPERATORS:
81
+ if expecting_term:
82
+ msg = f"The {operator} operator needs a word on both sides: {text!r}"
83
+ raise QueryParseError(msg)
84
+ pending_operator = operator
85
+ expecting_term = True
86
+ continue
87
+ term = _word_query(word)
88
+ if term is None:
89
+ continue # a stop word or bare punctuation -- nothing to search for
90
+ result = term if result is None else _combine(result, pending_operator, term)
91
+ pending_operator = "AND"
92
+ expecting_term = False
93
+
94
+ if expecting_term and result is not None:
95
+ msg = f"The {pending_operator} operator needs a word on both sides: {text!r}"
96
+ raise QueryParseError(msg)
97
+ if result is None:
98
+ msg = f"Query has no searchable words (only stop words?): {text!r}"
99
+ raise QueryParseError(msg)
100
+ return result
101
+
102
+
103
+ def _word_query(word: str) -> SearchQuery | None:
104
+ """Turn one typed word into a query (several pieces must all match)."""
105
+ pieces = [TermQuery(term=piece) for piece in split_words(word)]
106
+ if not pieces:
107
+ return None
108
+ query: SearchQuery = pieces[0]
109
+ for piece in pieces[1:]:
110
+ query = AndQuery(left=query, right=piece)
111
+ return query
112
+
113
+
114
+ def _combine(left: SearchQuery, operator: str, right: SearchQuery) -> SearchQuery:
115
+ """Join two queries with AND or OR."""
116
+ if operator == "OR":
117
+ return OrQuery(left=left, right=right)
118
+ return AndQuery(left=left, right=right)
pysearch/tfidf.py ADDED
@@ -0,0 +1,94 @@
1
+ """Compute TF-IDF relevance scores.
2
+
3
+ TF-IDF stands for Term Frequency - Inverse Document Frequency. It answers the
4
+ question: "How important is this word to this particular document?" A word
5
+ that appears many times in one document but rarely in others gets a high
6
+ score -- it is probably what that document is *about*.
7
+ """
8
+
9
+ import math
10
+
11
+ from pysearch.index import InvertedIndex
12
+
13
+
14
+ def compute_tf(term: str, doc_id: str, index: InvertedIndex) -> float:
15
+ """Tally how many times a word shows up in one document -- more mentions, more relevant.
16
+
17
+ Args:
18
+ term: The search term.
19
+ doc_id: The document identifier.
20
+ index: The inverted index to query.
21
+
22
+ Returns:
23
+ The raw count of *term* in *doc_id* as a float.
24
+
25
+ """
26
+ return float(index.get_term_frequency(term, doc_id))
27
+
28
+
29
+ def compute_idf(term: str, index: InvertedIndex) -> float:
30
+ """Figure out how special a word is -- common words like "the" score near zero, rare words like "dinosaur" score high.
31
+
32
+ Uses the formula ``log(N / df)`` where *N* is the total number of
33
+ documents and *df* is the number of documents containing *term*. Note that a
34
+ word appearing in *every* document (or the only document) scores exactly
35
+ ``0.0`` -- a fun exercise is to smooth this to ``log(N / df) + 1`` so even a
36
+ lone match keeps a small positive score.
37
+
38
+ Args:
39
+ term: The search term.
40
+ index: The inverted index to query.
41
+
42
+ Returns:
43
+ The IDF value, or 0.0 if the term appears in no documents.
44
+
45
+ """
46
+ df = index.get_document_frequency(term)
47
+ if df == 0:
48
+ return 0.0
49
+ return math.log(index.document_count / df)
50
+
51
+
52
+ def compute_tfidf(term: str, doc_id: str, index: InvertedIndex) -> float:
53
+ """Blend "how often" with "how special" into one relevance score for a word in a document.
54
+
55
+ Args:
56
+ term: The search term.
57
+ doc_id: The document identifier.
58
+ index: The inverted index to query.
59
+
60
+ Returns:
61
+ The product of TF and IDF.
62
+
63
+ """
64
+ return compute_tf(term, doc_id, index) * compute_idf(term, index)
65
+
66
+
67
+ def rank_results(
68
+ terms: str | list[str],
69
+ doc_ids: list[str],
70
+ index: InvertedIndex,
71
+ ) -> list[tuple[str, float]]:
72
+ """Rank documents by their TF-IDF score across all *terms*, highest first.
73
+
74
+ Think of it like a school report card: each subject (term) gives a grade
75
+ (TF-IDF score), and we add them all up to get the final grade. A document
76
+ that scores well on *every* search word floats to the top.
77
+
78
+ Args:
79
+ terms: A single search term or a list of terms.
80
+ doc_ids: The candidate document IDs to rank.
81
+ index: The inverted index to query.
82
+
83
+ Returns:
84
+ A list of ``(doc_id, score)`` tuples sorted by score descending.
85
+
86
+ """
87
+ term_list = [terms] if isinstance(terms, str) else terms
88
+ scored = [
89
+ (doc_id, sum(compute_tfidf(t, doc_id, index) for t in term_list)) for doc_id in doc_ids
90
+ ]
91
+ # Best score first; equal scores are listed by document id so the
92
+ # order never depends on luck.
93
+ scored.sort(key=lambda pair: (-pair[1], pair[0]))
94
+ return scored
pysearch/tokenizer.py ADDED
@@ -0,0 +1,96 @@
1
+ """Tokenize text into searchable words.
2
+
3
+ Think of a tokenizer like cutting sentences into word cards. You take a long
4
+ sentence, snip it into individual words, throw away boring words like "the"
5
+ and "is," and keep the interesting ones. Each card can then be filed in the
6
+ index so you can find it later.
7
+ """
8
+
9
+ import re
10
+
11
+ STOP_WORDS: frozenset[str] = frozenset(
12
+ {
13
+ "the",
14
+ "a",
15
+ "an",
16
+ "is",
17
+ "are",
18
+ "was",
19
+ "were",
20
+ "in",
21
+ "on",
22
+ "at",
23
+ "to",
24
+ "for",
25
+ "of",
26
+ "and",
27
+ "or",
28
+ "but",
29
+ "not",
30
+ "it",
31
+ "this",
32
+ "that",
33
+ "with",
34
+ "from",
35
+ "by",
36
+ }
37
+ )
38
+
39
+ _SUFFIX_ORDER: tuple[str, ...] = ("ing", "ed", "ly", "er", "est", "es", "s")
40
+ _MIN_STEM_LENGTH = 3
41
+
42
+
43
+ def split_words(text: str) -> list[str]:
44
+ """Cut text into clean words -- lowercased, punctuation gone, boring words tossed.
45
+
46
+ This is the first half of :func:`tokenize`, without the stemming. Search
47
+ queries use it too, so a query word is cleaned up *exactly* the way the
48
+ documents were: ``"fox,"`` becomes ``"fox"``, and ``"state-of-the-art"``
49
+ becomes ``["state", "art"]``.
50
+
51
+ Args:
52
+ text: The raw text to split.
53
+
54
+ Returns:
55
+ A list of lowercase words with stop words removed (not yet stemmed).
56
+
57
+ """
58
+ words = re.split(r"[^a-zA-Z0-9]+", text.lower())
59
+ return [w for w in words if w and w not in STOP_WORDS]
60
+
61
+
62
+ def tokenize(text: str) -> list[str]:
63
+ """Cut a sentence into clean word cards -- lowercased, punctuation gone, boring words tossed.
64
+
65
+ Args:
66
+ text: The raw text to tokenize.
67
+
68
+ Returns:
69
+ A list of stemmed, lowercase tokens with stop words removed.
70
+
71
+ """
72
+ return [simple_stem(w) for w in split_words(text)]
73
+
74
+
75
+ def simple_stem(word: str) -> str:
76
+ """Trim a word down to its root so "foxes" and "fox" count as the same word.
77
+
78
+ We only chop an ending off if enough of the word is left over (at least 3
79
+ letters), so tiny words like "bed" stay whole. Plural "-ies" becomes "-y"
80
+ ("berries" -> "berry"), plural "-es" is dropped ("foxes" -> "fox"), and the
81
+ other endings checked in order are: "ing", "ed", "ly", "er", "est", "s".
82
+
83
+ Args:
84
+ word: The word to stem.
85
+
86
+ Returns:
87
+ The stemmed word.
88
+
89
+ """
90
+ # Plural "-ies" -> "-y" so "berries" and "berry" collapse to the same stem.
91
+ if word.endswith("ies") and len(word) - len("ies") >= _MIN_STEM_LENGTH - 1:
92
+ return word[:-3] + "y"
93
+ for suffix in _SUFFIX_ORDER:
94
+ if word.endswith(suffix) and len(word) - len(suffix) >= _MIN_STEM_LENGTH:
95
+ return word[: -len(suffix)]
96
+ return word