feat: markdown dialect option, faster search (0.1.1)

- split_markdown/load_markdown: dialect="commonmark"|"python-markdown" so list
  blocks match the HTML structure of the renderer (list after paragraph needs a
  blank line; nesting needs 4 spaces in Python-Markdown)
- SearchEngine caches the searched text per block on load
- regex timeout only for /regex/ terms (words and phrases are linear);
  typical queries over 26k blocks: ~30-140 ms instead of 250-700 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
dschlueter 2026-10-12 19:37:40 +02:00
commit d9f81ac918
7 changed files with 85 additions and 33 deletions

View file

@ -12,8 +12,8 @@ Boolean full-text search over Markdown (and any other text) – as a **Python li
## Installation ## Installation
```bash ```bash
pip install "mdgrep @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.0" # library + CLI pip install "mdgrep @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.1" # library + CLI
pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.0" # + Flask API pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.1" # + Flask API
``` ```
Requires Python ≥ 3.10. Dependencies: [`regex`](https://pypi.org/project/regex/) (and `flask` for `[web]`). Requires Python ≥ 3.10. Dependencies: [`regex`](https://pypi.org/project/regex/) (and `flask` for `[web]`).
@ -97,6 +97,10 @@ engine.search("login OR sso", filters={"status": "open"})
`load_markdown()` adds `meta["section"]` (nearest heading), `meta["kind"]` `load_markdown()` adds `meta["section"]` (nearest heading), `meta["kind"]`
(`file`/`section`/`item`/`paragraph`) and `meta["markdown"]` (raw source). (`file`/`section`/`item`/`paragraph`) and `meta["markdown"]` (raw source).
`dialect` (for `split="list"`) makes blocks line up with the HTML of your Markdown renderer:
`"commonmark"` (default) or `"python-markdown"` (a list needs a blank line before it; nesting
needs 4 spaces of indentation). Important if you link results to element ids in rendered HTML.
### `SearchEngine(source, *, reload_check=None, reload_interval=60)` ### `SearchEngine(source, *, reload_check=None, reload_interval=60)`
`source` is a list of blocks or a callable returning them. With a callable plus `reload_check` `source` is a list of blocks or a callable returning them. With a callable plus `reload_check`

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "mdgrep" name = "mdgrep"
version = "0.1.0" version = "0.1.1"
description = "Boolean full-text search (substrings, phrases, regex, AND/OR/NOT, parentheses) over Markdown and other text blocks, with CLI, Flask API and a JavaScript search widget" description = "Boolean full-text search (substrings, phrases, regex, AND/OR/NOT, parentheses) over Markdown and other text blocks, with CLI, Flask API and a JavaScript search widget"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"

View file

@ -5,7 +5,7 @@ from .markdown import load_markdown, markdown_to_text, split_markdown
from .query import Query, QuerySyntaxError, QueryTimeout, compile_query from .query import Query, QuerySyntaxError, QueryTimeout, compile_query
from .snippets import segments from .snippets import segments
__version__ = "0.1.0" __version__ = "0.1.1"
STATIC_FILES = ("mdgrep-widget.js", "mdgrep-widget.css") STATIC_FILES = ("mdgrep-widget.js", "mdgrep-widget.css")

View file

@ -103,12 +103,17 @@ class SearchEngine:
self._lock = threading.Lock() self._lock = threading.Lock()
self._token = reload_check() if reload_check else None self._token = reload_check() if reload_check else None
self._checked_at = time.monotonic() self._checked_at = time.monotonic()
self._blocks: list[Block] = list(source() if callable(source) else source) self._set_blocks(list(source() if callable(source) else source))
def _set_blocks(self, blocks: list[Block]) -> None:
# Text that is searched (title + text), precomputed once per load.
haystacks = [f"{b.title}\n{b.text}" if b.title else b.text for b in blocks]
self._data = (blocks, haystacks)
@property @property
def blocks(self) -> list[Block]: def blocks(self) -> list[Block]:
self._maybe_reload() self._maybe_reload()
return self._blocks return self._data[0]
def reload(self) -> None: def reload(self) -> None:
"""Reload blocks from the loader (no-op for a static block list).""" """Reload blocks from the loader (no-op for a static block list)."""
@ -116,7 +121,7 @@ class SearchEngine:
return return
blocks = list(self._loader()) blocks = list(self._loader())
with self._lock: with self._lock:
self._blocks = blocks self._set_blocks(blocks)
self._token = self._reload_check() if self._reload_check else None self._token = self._reload_check() if self._reload_check else None
self._checked_at = time.monotonic() self._checked_at = time.monotonic()
@ -160,14 +165,19 @@ class SearchEngine:
started = time.monotonic() started = time.monotonic()
q = query if isinstance(query, Query) else compile_query(query) q = query if isinstance(query, Query) else compile_query(query)
deadline = started + time_budget if time_budget else None deadline = started + time_budget if time_budget else None
candidates = self._filter(self.blocks, filters, predicate) self._maybe_reload()
blocks, haystacks = self._data
conditions = self._conditions(filters)
matched: list[Block] = [] matched: list[Block] = []
truncated = False truncated = False
for block in candidates: for i, block in enumerate(blocks):
if conditions and any(block.meta.get(k) not in allowed for k, allowed in conditions):
continue
if predicate is not None and not predicate(block):
continue
try: try:
if q.matches(f"{block.title}\n{block.text}" if block.title else block.text, if q.matches(haystacks[i], deadline=deadline):
deadline=deadline):
matched.append(block) matched.append(block)
except QueryTimeout: except QueryTimeout:
truncated = True truncated = True
@ -214,16 +224,11 @@ class SearchEngine:
) )
@staticmethod @staticmethod
def _filter(blocks, filters, predicate): def _conditions(filters):
conditions = [] conditions = []
for key, value in (filters or {}).items(): for key, value in (filters or {}).items():
if value is None or value == "" or value == []: if value is None or value == "" or value == []:
continue continue
allowed = set(value) if isinstance(value, (list, tuple, set, frozenset)) else {value} allowed = set(value) if isinstance(value, (list, tuple, set, frozenset)) else {value}
conditions.append((key, allowed)) conditions.append((key, allowed))
for block in blocks: return conditions
if any(block.meta.get(k) not in allowed for k, allowed in conditions):
continue
if predicate is not None and not predicate(block):
continue
yield block

View file

@ -9,6 +9,13 @@ Split modes:
Every block gets `meta["section"]` (nearest preceding heading text), `meta["kind"]` Every block gets `meta["section"]` (nearest preceding heading text), `meta["kind"]`
("file", "section", "item" or "paragraph") and `meta["markdown"]` (the raw source). ("file", "section", "item" or "paragraph") and `meta["markdown"]` (the raw source).
`dialect` (list mode) decides how list items are recognised, so blocks line up with the HTML
your Markdown renderer produces:
commonmark (default) a list may interrupt a paragraph; only unindented markers start
top-level items
python-markdown a list needs a blank line before it; markers indented by up to 3 spaces
still start top-level items (nesting needs 4 spaces)
The searchable `text` is plain text with Markdown syntax removed. In `file` and `heading` The searchable `text` is plain text with Markdown syntax removed. In `file` and `heading`
mode the heading becomes the block title (and is removed from the text); list items and mode the heading becomes the block title (and is removed from the text); list items and
paragraphs have no title. paragraphs have no title.
@ -22,19 +29,22 @@ from typing import Callable, Iterable
from .engine import Block from .engine import Block
__all__ = ["load_markdown", "split_markdown", "markdown_to_text", "slugify", "SPLIT_MODES"] __all__ = ["load_markdown", "split_markdown", "markdown_to_text", "slugify", "SPLIT_MODES", "DIALECTS"]
SPLIT_MODES = ("file", "heading", "list", "paragraph") SPLIT_MODES = ("file", "heading", "list", "paragraph")
DIALECTS = ("commonmark", "python-markdown")
_HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$") _HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
_FENCE = re.compile(r"^\s*(```|~~~)") _FENCE = re.compile(r"^\s*(```|~~~)")
_LIST_ITEM = re.compile(r"^([-*+]|\d{1,9}[.)])\s+") _LIST_ITEM = re.compile(r"^([-*+]|\d{1,9}[.)])\s+")
_LIST_ITEM_PYMD = re.compile(r"^ {0,3}([-*+]|\d{1,9}\.)\s+")
_HR = re.compile(r"^\s*([-*_])(\s*\1){2,}\s*$") _HR = re.compile(r"^\s*([-*_])(\s*\1){2,}\s*$")
def load_markdown(paths: str | Path | Iterable[str | Path], *, split: str = "paragraph", def load_markdown(paths: str | Path | Iterable[str | Path], *, split: str = "paragraph",
meta: Callable[[Path], dict] | None = None, encoding: str = "utf-8", meta: Callable[[Path], dict] | None = None, encoding: str = "utf-8",
pattern: str = "*.md", relative_to: str | Path | None = None) -> list[Block]: pattern: str = "*.md", relative_to: str | Path | None = None,
dialect: str = "commonmark") -> list[Block]:
"""Load Markdown files (directories are searched recursively for `pattern`). """Load Markdown files (directories are searched recursively for `pattern`).
`meta(path)` may return a dict merged into every block's meta of that file. `meta(path)` may return a dict merged into every block's meta of that file.
@ -47,7 +57,7 @@ def load_markdown(paths: str | Path | Iterable[str | Path], *, split: str = "par
text = path.read_text(encoding=encoding) text = path.read_text(encoding=encoding)
extra = meta(path) if meta else {} extra = meta(path) if meta else {}
source = path.resolve().relative_to(base).as_posix() if base else str(path) source = path.resolve().relative_to(base).as_posix() if base else str(path)
blocks.extend(split_markdown(text, source=source, split=split, meta=extra)) blocks.extend(split_markdown(text, source=source, split=split, meta=extra, dialect=dialect))
return blocks return blocks
@ -64,18 +74,19 @@ def _expand(paths, pattern):
def split_markdown(text: str, *, source: str = "", split: str = "paragraph", def split_markdown(text: str, *, source: str = "", split: str = "paragraph",
meta: dict | None = None) -> list[Block]: meta: dict | None = None, dialect: str = "commonmark") -> list[Block]:
"""Split one Markdown document into blocks (see module docstring for modes).""" """Split one Markdown document into blocks (see module docstring for modes and dialects)."""
if split not in SPLIT_MODES: if split not in SPLIT_MODES:
raise ValueError(f"split must be one of {SPLIT_MODES}, got {split!r}") raise ValueError(f"split must be one of {SPLIT_MODES}, got {split!r}")
if dialect not in DIALECTS:
raise ValueError(f"dialect must be one of {DIALECTS}, got {dialect!r}")
meta = meta or {} meta = meta or {}
lines = text.splitlines() lines = text.splitlines()
raw_blocks = { if split == "list":
"file": _split_file, raw_blocks = _split_list(lines, dialect == "python-markdown")
"heading": _split_heading, else:
"list": _split_list, raw_blocks = {"file": _split_file, "heading": _split_heading,
"paragraph": _split_paragraph, "paragraph": _split_paragraph}[split](lines)
}[split](lines)
blocks = [] blocks = []
for start, end, kind, section, title, anchor in raw_blocks: for start, end, kind, section, title, anchor in raw_blocks:
@ -127,7 +138,7 @@ def _split_heading(lines):
return out return out
def _split_list(lines): def _split_list(lines, pymd=False):
out = [] out = []
section = "" section = ""
cur = None # [start, kind] cur = None # [start, kind]
@ -139,6 +150,7 @@ def _split_list(lines):
out.append((cur[0], end, cur[1], section, "", "")) out.append((cur[0], end, cur[1], section, "", ""))
cur = None cur = None
item_re = _LIST_ITEM_PYMD if pymd else _LIST_ITEM
prev_blank = True prev_blank = True
for i, line, in_fence, heading in _scan(lines): for i, line, in_fence, heading in _scan(lines):
if heading: if heading:
@ -154,7 +166,9 @@ def _split_list(lines):
if in_fence: if in_fence:
if cur is None: if cur is None:
cur = [i, "paragraph"] cur = [i, "paragraph"]
elif _LIST_ITEM.match(line): elif pymd and cur is not None and cur[1] == "paragraph" and not prev_blank:
pass # Python-Markdown: no list without blank line
elif item_re.match(line):
close(last_content + 1) close(last_content + 1)
cur = [i, "item"] cur = [i, "item"]
elif _HR.match(line): elif _HR.match(line):

View file

@ -133,7 +133,11 @@ class Term:
pattern: regex.Pattern = field(repr=False) pattern: regex.Pattern = field(repr=False)
def evaluate(self, text: str, deadline: float | None) -> bool: def evaluate(self, text: str, deadline: float | None) -> bool:
# Words and phrases are escaped literals and run in linear time; only user-supplied
# regular expressions can backtrack catastrophically and need the (costly) timeout.
if self.kind == "regex":
return self.pattern.search(text, timeout=_remaining(deadline)) is not None return self.pattern.search(text, timeout=_remaining(deadline)) is not None
return self.pattern.search(text) is not None
@dataclass @dataclass
@ -285,6 +289,7 @@ class Query:
if deadline is None and timeout is not None: if deadline is None and timeout is not None:
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
try: try:
_remaining(deadline)
return self._root.evaluate(text, deadline) return self._root.evaluate(text, deadline)
except TimeoutError as e: except TimeoutError as e:
raise QueryTimeout(str(e) or "Search time limit exceeded") from None raise QueryTimeout(str(e) or "Search time limit exceeded") from None
@ -296,7 +301,8 @@ class Query:
found = [] found = []
try: try:
for term in self.positive_terms: for term in self.positive_terms:
for m in term.pattern.finditer(text, timeout=_remaining(deadline)): timeout = _remaining(deadline) if term.kind == "regex" else None
for m in term.pattern.finditer(text, timeout=timeout):
if m.end() > m.start(): if m.end() > m.start():
found.append((m.start(), m.end())) found.append((m.start(), m.end()))
except TimeoutError as e: except TimeoutError as e:

View file

@ -63,6 +63,29 @@ def test_split_list():
assert "not a heading" in blocks[6].text assert "not a heading" in blocks[6].text
def test_dialect_list_interrupting_paragraph():
doc = "**1. Trend**\n* stories: a\n* why: b\n\n- real item\n"
common = split_markdown(doc, split="list")
assert [b.meta["kind"] for b in common] == ["paragraph", "item", "item", "item"]
pymd = split_markdown(doc, split="list", dialect="python-markdown")
assert [b.meta["kind"] for b in pymd] == ["paragraph", "item"]
assert "stories: a" in pymd[0].text
def test_dialect_nesting_indent():
doc = "1. **Trend A**\n - why: x\n - how: y\n\n2. **Trend B**\n - nested: z\n"
common = split_markdown(doc, split="list")
assert [b.line for b in common] == [1, 5]
pymd = split_markdown(doc, split="list", dialect="python-markdown")
assert [b.line for b in pymd] == [1, 2, 3, 5] # 3 spaces: no nesting in Python-Markdown
assert "nested: z" in pymd[-1].text
def test_invalid_dialect():
with pytest.raises(ValueError):
split_markdown("x", split="list", dialect="gfm")
def test_split_heading(): def test_split_heading():
blocks = split_markdown(DOC, split="heading") blocks = split_markdown(DOC, split="heading")
assert [(b.title, b.anchor) for b in blocks] == [ assert [(b.title, b.anchor) for b in blocks] == [