markdown-memory 0.1.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.
- markdown_memory/__init__.py +47 -0
- markdown_memory/autoindex.py +170 -0
- markdown_memory/config.py +235 -0
- markdown_memory/db.py +1546 -0
- markdown_memory/discovery.py +195 -0
- markdown_memory/embedders.py +513 -0
- markdown_memory/exceptions.py +71 -0
- markdown_memory/freshness.py +138 -0
- markdown_memory/headings.py +185 -0
- markdown_memory/indexer.py +725 -0
- markdown_memory/model_cache.py +272 -0
- markdown_memory/models.py +339 -0
- markdown_memory/parser.py +869 -0
- markdown_memory/py.typed +0 -0
- markdown_memory/search.py +518 -0
- markdown_memory/server.py +520 -0
- markdown_memory-0.1.0.dist-info/METADATA +579 -0
- markdown_memory-0.1.0.dist-info/RECORD +21 -0
- markdown_memory-0.1.0.dist-info/WHEEL +4 -0
- markdown_memory-0.1.0.dist-info/entry_points.txt +3 -0
- markdown_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Finding the Markdown files to index, and reading them safely.
|
|
2
|
+
|
|
3
|
+
Everything here is about the filesystem and nothing about embedding: which paths a walk
|
|
4
|
+
should yield, which it must skip, and how to read one without being taken somewhere else
|
|
5
|
+
by a symlink or blocked forever by a FIFO.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
14
|
+
from fnmatch import fnmatchcase
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
#: What ``_printable`` leaves where it could not decode a byte of a file name.
|
|
18
|
+
_UNDECODABLE = "�"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
MARKDOWN_SUFFIXES = frozenset({".md", ".markdown"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
MAX_FILE_BYTES = 10 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_SKIPPED_DIRECTORIES = frozenset(
|
|
28
|
+
{
|
|
29
|
+
".git", ".hg", ".svn", "node_modules", ".venv", "venv", "__pycache__",
|
|
30
|
+
".mypy_cache", ".ruff_cache", ".pytest_cache", ".tox", "site-packages",
|
|
31
|
+
}
|
|
32
|
+
) # fmt: skip
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_regular_file(path: Path) -> bytes | None:
|
|
36
|
+
"""The file's bytes, or ``None`` if what opened is not a regular file after all.
|
|
37
|
+
|
|
38
|
+
`stat` and `open` are two moments, and between them a path can become a FIFO - at
|
|
39
|
+
which point a blocking open waits for a writer that may never come, holding whatever
|
|
40
|
+
the caller was holding: an indexing worker, or the lock a freshness sweep runs under.
|
|
41
|
+
Opening without blocking and asking the descriptor itself what it is closes that
|
|
42
|
+
window; `O_NONBLOCK` is then cleared, because it is only the open that must not block.
|
|
43
|
+
|
|
44
|
+
At most ``MAX_FILE_BYTES + 1`` bytes, so the caller can tell "too large" from "exactly
|
|
45
|
+
at the cap" without trusting `st_size`, which the file is free to disagree with.
|
|
46
|
+
"""
|
|
47
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
|
48
|
+
try:
|
|
49
|
+
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
|
50
|
+
return None
|
|
51
|
+
os.set_blocking(descriptor, True)
|
|
52
|
+
with os.fdopen(descriptor, "rb", closefd=False) as handle:
|
|
53
|
+
return handle.read(MAX_FILE_BYTES + 1)
|
|
54
|
+
finally:
|
|
55
|
+
os.close(descriptor)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def hash_bytes(data: bytes) -> str:
|
|
59
|
+
return hashlib.sha256(data).hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def iter_markdown_files(
|
|
63
|
+
directory: Path,
|
|
64
|
+
on_error: Callable[[OSError], None] | None = None,
|
|
65
|
+
exclude: Sequence[str] = (),
|
|
66
|
+
) -> Iterator[Path]:
|
|
67
|
+
"""Yield Markdown files beneath ``directory`` in a stable order, pruning vendored trees.
|
|
68
|
+
|
|
69
|
+
``on_error`` receives the ``OSError`` for every sub-directory that cannot be listed
|
|
70
|
+
(``os.walk`` would otherwise skip it silently).
|
|
71
|
+
|
|
72
|
+
``exclude`` holds glob patterns matched against each path *relative to* ``directory``
|
|
73
|
+
(``scripts/eval_data/*``, ``**/vendor/**``, ``CHANGELOG.md``). A repository that keeps
|
|
74
|
+
fixtures, vendored documentation or a test corpus in-tree would otherwise index them
|
|
75
|
+
as if they were its own documentation. A matching directory is pruned, so its subtree
|
|
76
|
+
costs nothing to skip.
|
|
77
|
+
"""
|
|
78
|
+
for root, dirnames, filenames in os.walk(directory, followlinks=False, onerror=on_error):
|
|
79
|
+
here = Path(root)
|
|
80
|
+
dirnames[:] = sorted(
|
|
81
|
+
name
|
|
82
|
+
for name in dirnames
|
|
83
|
+
if name not in _SKIPPED_DIRECTORIES
|
|
84
|
+
and not _is_excluded(here / name, directory, exclude)
|
|
85
|
+
)
|
|
86
|
+
for filename in sorted(filenames):
|
|
87
|
+
path = here / filename
|
|
88
|
+
if path.suffix.lower() in MARKDOWN_SUFFIXES and not _is_excluded(
|
|
89
|
+
path, directory, exclude
|
|
90
|
+
):
|
|
91
|
+
yield path
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _behind_symlink(root: Path, path: str) -> bool:
|
|
95
|
+
"""True when reaching ``path`` from ``root`` passes through a symlinked directory.
|
|
96
|
+
|
|
97
|
+
The walk sets ``followlinks=False``, so it never descends into one - it has no idea
|
|
98
|
+
what is in there, which is the same position an unreadable directory leaves it in. A
|
|
99
|
+
run that treats "I did not look" as "there is nothing there" purges documents that are
|
|
100
|
+
still on disk and still readable at that path, and clears failures it never rechecked.
|
|
101
|
+
"""
|
|
102
|
+
current = root
|
|
103
|
+
for part in os.path.relpath(path, root).split(os.sep)[:-1]:
|
|
104
|
+
current = current / part
|
|
105
|
+
if current.is_symlink():
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _certainly_gone(root: Path, path: str) -> bool:
|
|
111
|
+
"""True when ``path`` is observably absent, rather than merely out of the walk's sight.
|
|
112
|
+
|
|
113
|
+
Everything else here refuses to draw conclusions from what the walk did not visit, and
|
|
114
|
+
that refusal has one consequence nobody wanted: a row about a pruned, excluded or
|
|
115
|
+
symlink-shadowed path can never be retired, because the walk that would retire it never
|
|
116
|
+
goes there. The file is then deleted and the row outlives it - the root it sits under
|
|
117
|
+
reports itself incomplete forever, naming a path that no longer exists, and no run can
|
|
118
|
+
ever change that answer.
|
|
119
|
+
|
|
120
|
+
Not looking is not evidence. Looking at that one path is: `lstat` separates "there is
|
|
121
|
+
nothing here" (`ENOENT`) from "I am not allowed to know" (`EACCES`, a loop, a dead
|
|
122
|
+
mount), and only the first retires anything. That is a direct observation of one name,
|
|
123
|
+
not an inference from a walk's silence, which is why it is safe where the walk is not.
|
|
124
|
+
|
|
125
|
+
Two things make that observation worthless, and both answer `ENOENT` about a path that
|
|
126
|
+
was never the file's. A name that is not valid UTF-8 reached the row through
|
|
127
|
+
`_printable`, which substitutes U+FFFD for the bytes it could not decode - the stored
|
|
128
|
+
string is a rendering of the name, not the name, and nothing is at it. (Testing that
|
|
129
|
+
the string survives `_printable` does not find this: the replacement character is
|
|
130
|
+
itself valid UTF-8 and round-trips.) And a path reached through a symlink answers for
|
|
131
|
+
the link's target: replace a real directory with a broken link and every document under
|
|
132
|
+
it reports `ENOENT` while the files sit untouched wherever they were moved to. Neither
|
|
133
|
+
is evidence of a deletion.
|
|
134
|
+
"""
|
|
135
|
+
if _UNDECODABLE in path:
|
|
136
|
+
return False
|
|
137
|
+
if _behind_symlink(root, path) or os.path.islink(path):
|
|
138
|
+
return False
|
|
139
|
+
try:
|
|
140
|
+
os.lstat(path)
|
|
141
|
+
except FileNotFoundError:
|
|
142
|
+
return True
|
|
143
|
+
except OSError: # no permission, symlink loop, unreachable mount: no evidence either way
|
|
144
|
+
return False
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _is_shadowing_symlink(path: str) -> bool:
|
|
149
|
+
"""True when ``path`` is a symlink the walk sees the name of but never follows.
|
|
150
|
+
|
|
151
|
+
`os.walk(followlinks=False)` lists a symlinked directory among its parent's names and
|
|
152
|
+
stops there, so a failure recorded against that directory cannot be rechecked by any
|
|
153
|
+
walk of the tree above it. `_behind_symlink` cannot answer this: it tests the
|
|
154
|
+
components *before* the last one, which is right for a file inside a linked tree and
|
|
155
|
+
blind to the linked directory itself. A symlink to a *file* is walked and indexed like
|
|
156
|
+
any other file, so only one that does not resolve to a file is out of reach.
|
|
157
|
+
"""
|
|
158
|
+
return os.path.islink(path) and not os.path.isfile(path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _is_excluded(path: Path, root: Path, patterns: Sequence[str]) -> bool:
|
|
162
|
+
"""True when ``path`` matches a pattern, tested against its path relative to ``root``.
|
|
163
|
+
|
|
164
|
+
A pattern with no ``/`` matches a *name* anywhere in the tree, the way ``.gitignore``
|
|
165
|
+
treats one: ``eval_data`` excludes ``scripts/eval_data/corpus/a.md``. Requiring the
|
|
166
|
+
full relative path there was a trap - the pattern looked right, matched nothing, and
|
|
167
|
+
the files were indexed silently. A pattern that does contain ``/`` is anchored at the
|
|
168
|
+
root and matched with ``fnmatchcase`` (never the platform's case folding), plus an
|
|
169
|
+
implied ``/*`` so naming a directory covers its subtree.
|
|
170
|
+
"""
|
|
171
|
+
if not patterns:
|
|
172
|
+
return False
|
|
173
|
+
try:
|
|
174
|
+
relative = path.relative_to(root)
|
|
175
|
+
except ValueError: # outside the root: nothing to match against
|
|
176
|
+
return False
|
|
177
|
+
text = relative.as_posix()
|
|
178
|
+
parts = relative.parts
|
|
179
|
+
for pattern in patterns:
|
|
180
|
+
if "/" in pattern:
|
|
181
|
+
anchored = pattern.rstrip("/")
|
|
182
|
+
if fnmatchcase(text, anchored) or fnmatchcase(text, anchored + "/*"):
|
|
183
|
+
return True
|
|
184
|
+
elif any(fnmatchcase(part, pattern) for part in parts):
|
|
185
|
+
return True
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _printable(path: str) -> str:
|
|
190
|
+
"""``path`` safe to log and to send as JSON (undecodable bytes become U+FFFD)."""
|
|
191
|
+
return os.fsencode(path).decode("utf-8", errors="replace")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_walkable(relative_directories: Sequence[str]) -> bool:
|
|
195
|
+
return not any(name in _SKIPPED_DIRECTORIES for name in relative_directories)
|