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.
@@ -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)