chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:54 +08:00
commit 4a4a1fed67
721 changed files with 262090 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Tests for the native markdown parser engine (.md / .textpack)."""
+101
View File
@@ -0,0 +1,101 @@
"""End-to-end: markdown → IRDoc → sidecar files, and re-parse stability.
Drives the parser's ``extract`` + ``build_ir`` hooks through the real
``write_sidecar`` (the same path ``NativeParserBase.parse`` uses) and asserts
the produced sidecar shape, then re-runs to confirm byte-stable content blocks
(no runtime stamping leaks into the per-block content).
"""
from __future__ import annotations
import json
from pathlib import Path
from lightrag.parser.markdown.parser import NativeMarkdownParser
from lightrag.sidecar import write_sidecar
_MD = """# Doc Title
Intro ![x](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==).
## Section A
| Name | Age |
|------|-----|
| Bob | 30 |
$$
E = mc^2
$$
<table><thead><tr><th>K</th></tr></thead><tbody><tr><td>a</td></tr></tbody></table>
"""
def _build_sidecar(tmp_path: Path) -> Path:
parser = NativeMarkdownParser()
parsed_dir = tmp_path / "doc.md.parsed"
asset_dir = parsed_dir / "doc.blocks.assets"
parsed_dir.mkdir(parents=True)
asset_dir.mkdir()
blocks, _, meta = parser._extract_text(_MD, bundle_root=None)
ir = parser.build_ir(
blocks,
document_name="doc.md",
asset_dir_name=asset_dir.name,
metadata=meta,
)
write_sidecar(
ir,
parsed_dir=parsed_dir,
doc_id="doc-test",
engine="native",
clean_parsed_dir=False,
block_drawing_path_style=parser.sidecar_path_style,
)
return parsed_dir
def _content_lines(parsed_dir: Path) -> list[str]:
lines = (parsed_dir / "doc.blocks.jsonl").read_text().splitlines()
return [line for line in lines if json.loads(line).get("type") == "content"]
def test_sidecar_files_and_payload(tmp_path: Path):
parsed_dir = _build_sidecar(tmp_path)
assert (parsed_dir / "doc.blocks.jsonl").is_file()
assert (parsed_dir / "doc.tables.json").is_file()
assert (parsed_dir / "doc.equations.json").is_file()
assert (parsed_dir / "doc.drawings.json").is_file()
assets = list((parsed_dir / "doc.blocks.assets").iterdir())
assert len(assets) == 1 and assets[0].read_bytes().startswith(b"\x89PNG")
meta_line = json.loads(
(parsed_dir / "doc.blocks.jsonl").read_text().splitlines()[0]
)
assert meta_line["document_format"] == "md"
assert meta_line["doc_title"] == "Doc Title"
assert meta_line["parse_engine"] == "native"
tables = json.loads((parsed_dir / "doc.tables.json").read_text())["tables"]
fmts = sorted(t["format"] for t in tables.values())
assert fmts == ["html", "json"]
# Pipe-table header survives as a JSON grid; HTML header as a <thead>.
json_table = next(t for t in tables.values() if t["format"] == "json")
assert json.loads(json_table["table_header"]) == [["Name", "Age"]]
html_table = next(t for t in tables.values() if t["format"] == "html")
assert "<thead>" in html_table["table_header"]
equations = json.loads((parsed_dir / "doc.equations.json").read_text())["equations"]
assert any(e["content"] == "E = mc^2" for e in equations.values())
drawings = json.loads((parsed_dir / "doc.drawings.json").read_text())["drawings"]
(drawing,) = drawings.values()
assert drawing["path"].startswith("doc.blocks.assets/")
assert drawing["format"] == "png"
def test_reparse_content_is_byte_stable(tmp_path: Path):
first = _content_lines(_build_sidecar(tmp_path / "a"))
second = _content_lines(_build_sidecar(tmp_path / "b"))
assert first == second
+182
View File
@@ -0,0 +1,182 @@
"""Unit tests for ``lightrag.parser.markdown.extract`` (pure extraction).
Locks in the supported-subset contract: ATX heading splitting, fenced-code
suppression, pipe / HTML table recognition with headers, block-level ``$$``
math (and inline ``$`` left alone), and inline image resolution via a stubbed
resolver.
"""
from __future__ import annotations
from lightrag.parser.markdown.extract import (
PREFACE_HEADING,
ResolvedImage,
extract_markdown,
)
class _StubResolver:
"""Resolves ``http(s)`` → external link, everything else → local bytes."""
def __init__(self) -> None:
self.calls: list[str] = []
def resolve(self, src: str) -> ResolvedImage:
self.calls.append(src)
if src.startswith(("http://", "https://")):
return ResolvedImage(kind="external", url=src, fmt="png")
return ResolvedImage(
kind="local",
asset_ref="sha:" + src,
data=b"BYTES:" + src.encode(),
suggested_name="img.png",
fmt="png",
)
def _extract(md: str):
return extract_markdown(md, image_resolver=_StubResolver())
def test_headings_split_blocks_and_track_parents():
md = "# A\nintro\n## B\nbody\n### C\ndeep\n# D\nlast"
ex = _extract(md)
summary = [(b["heading"], b["level"], b["parent_headings"]) for b in ex.blocks]
assert summary == [
("A", 1, []),
("B", 2, ["A"]),
("C", 3, ["A", "B"]),
("D", 1, []),
]
# Heading line is rendered into the block body (markdown-style).
assert ex.blocks[0]["content"].startswith("# A")
def test_content_before_first_heading_is_preface():
ex = _extract("loose intro line\n\n# Real")
assert ex.blocks[0]["heading"] == PREFACE_HEADING
assert ex.blocks[0]["level"] == 0
assert "loose intro line" in ex.blocks[0]["content"]
def test_fenced_code_suppresses_all_detection():
md = "# H\n```python\n# not a heading\n$$ not eq $$\n| not | table |\n![x](no.png)\n```\n"
ex = _extract(md)
# Only the single real heading produced a block; fence content stays verbatim.
assert len(ex.blocks) == 1
assert not ex.tables and not ex.equations and not ex.drawings
assert "# not a heading" in ex.blocks[0]["content"]
assert "$$ not eq $$" in ex.blocks[0]["content"]
def test_tilde_fence_supported():
md = "# H\n~~~\n## still code\n~~~\nafter"
ex = _extract(md)
assert len(ex.blocks) == 1
assert "## still code" in ex.blocks[0]["content"]
def test_pipe_table_with_header():
md = "| Name | Age |\n|------|-----|\n| Bob | 30 |\n| Sue | 25 |\n"
ex = _extract(md)
assert len(ex.tables) == 1
(table,) = ex.tables.values()
assert table["kind"] == "pipe"
assert table["rows"] == [["Bob", "30"], ["Sue", "25"]]
assert table["header"] == [["Name", "Age"]]
def test_pipe_table_requires_delimiter_row():
# A pipe-containing line with no delimiter row underneath is plain text.
md = "| just | text |\nnot a delimiter\n"
ex = _extract(md)
assert not ex.tables
def test_pipe_line_over_thematic_break_is_not_a_table():
# A pipe-containing paragraph followed by a bare ``---`` (thematic break /
# setext underline) has mismatched column counts (2 vs 1) and so is NOT a
# GFM table — it must stay plain text rather than be misrecognised.
md = "foo | bar\n---\nnext paragraph\n"
ex = _extract(md)
assert not ex.tables
assert "foo | bar" in ex.blocks[0]["content"]
def test_pipe_table_column_count_must_match_header():
# Delimiter row column count differs from the header → not a table.
md = "| a | b | c |\n| --- | --- |\n| 1 | 2 | 3 |\n"
ex = _extract(md)
assert not ex.tables
def test_html_table_captured_verbatim_spanning_lines():
md = (
"<table>\n"
"<thead><tr><th>K</th></tr></thead>\n"
"<tbody><tr><td>a</td></tr></tbody>\n"
"</table>\n"
)
ex = _extract(md)
assert len(ex.tables) == 1
(table,) = ex.tables.values()
assert table["kind"] == "html"
assert "<thead>" in table["html"] and "</table>" in table["html"]
def test_block_equation_single_and_multiline():
md = "$$ E = mc^2 $$\n\ntext\n\n$$\n\\sum x\n$$\n"
ex = _extract(md)
latexes = list(ex.equations.values())
assert "E = mc^2" in latexes
assert "\\sum x" in latexes
def test_inline_dollar_not_recognized_as_equation():
ex = _extract("cost is $5 and $10 today")
assert not ex.equations
assert "$5" in ex.blocks[0]["content"]
def test_unclosed_block_equation_is_plain_text():
ex = _extract("$$\nE = mc^2\nno closing")
assert not ex.equations
assert "$$" in ex.blocks[0]["content"]
def test_inline_image_local_and_external_and_dedup():
md = "![a](one.png) and ![b](one.png) and ![c](http://x/y.png)"
resolver = _StubResolver()
ex = extract_markdown(md, image_resolver=resolver)
# Three occurrences → three drawings; same local src deduped to one asset.
assert len(ex.drawings) == 3
assert len(ex.assets) == 1
kinds = sorted(d["kind"] for d in ex.drawings.values())
assert kinds == ["external", "local", "local"]
# Asset dedup is by ``asset_ref`` in extract (the resolver itself owns any
# per-src call caching), so the stub sees one call per occurrence.
assert resolver.calls.count("one.png") == 2
def test_base64_src_not_echoed_into_drawing_src():
md = "![x](data:image/png;base64,QUJD)"
class _B64:
def resolve(self, src):
return ResolvedImage(
kind="local",
asset_ref="sha:b64",
data=b"ABC",
suggested_name="image.png",
fmt="png",
)
ex = extract_markdown(md, image_resolver=_B64())
(drawing,) = ex.drawings.values()
assert drawing["src"] == ""
def test_reference_style_image_not_recognized():
# ``![alt][id]`` reference-style is out of the supported subset.
ex = _extract("![alt][id]\n\n[id]: real.png")
assert not ex.drawings
+155
View File
@@ -0,0 +1,155 @@
"""Unit tests for ``NativeMarkdownIRBuilder`` (markers + side tables → IRDoc)."""
from __future__ import annotations
from lightrag.parser.markdown.extract import (
drawing_marker,
equation_marker,
table_marker,
)
from lightrag.parser.markdown.ir_builder import NativeMarkdownIRBuilder
def _normalize(blocks, meta, *, document_name="doc.md"):
return NativeMarkdownIRBuilder().normalize(
blocks,
document_name=document_name,
asset_dir_name="doc.blocks.assets",
parse_metadata=meta,
)
def test_pipe_table_becomes_json_irtable_with_header_grid():
blocks = [
{
"heading": "H",
"level": 1,
"parent_headings": [],
"content": table_marker("t1"),
}
]
meta = {
"md_tables": {
"t1": {"kind": "pipe", "rows": [["a", "b"]], "header": [["H1", "H2"]]}
}
}
ir = _normalize(blocks, meta)
(table,) = ir.blocks[0].tables
assert "{{TBL:" in ir.blocks[0].content_template
assert table.rows == [["a", "b"]]
assert table.html is None
assert table.table_header == [["H1", "H2"]]
assert (table.num_rows, table.num_cols) == (1, 2)
def test_html_table_becomes_html_irtable_with_thead_and_dims():
html = "<table><thead><tr><th>K</th><th>V</th></tr></thead><tbody><tr><td>a</td><td>1</td></tr></tbody></table>"
blocks = [
{
"heading": "H",
"level": 1,
"parent_headings": [],
"content": table_marker("t1"),
}
]
ir = _normalize(blocks, {"md_tables": {"t1": {"kind": "html", "html": html}}})
(table,) = ir.blocks[0].tables
assert table.rows is None
assert table.html and table.html.startswith("<table>")
assert isinstance(table.table_header, str) and "<thead>" in table.table_header
assert table.body_override and "<thead>" in table.body_override
assert (table.num_rows, table.num_cols) == (2, 2)
def test_equation_is_block_level():
blocks = [
{
"heading": "H",
"level": 1,
"parent_headings": [],
"content": equation_marker("e1"),
}
]
ir = _normalize(blocks, {"md_equations": {"e1": "E = mc^2"}})
(eq,) = ir.blocks[0].equations
assert eq.is_block is True
assert eq.latex == "E = mc^2"
assert "{{EQ:" in ir.blocks[0].content_template
def test_local_drawing_dedups_assetspec_across_occurrences():
content = f"{drawing_marker('d1')} {drawing_marker('d2')}"
blocks = [{"heading": "H", "level": 1, "parent_headings": [], "content": content}]
meta = {
"md_drawings": {
"d1": {"kind": "local", "asset_ref": "sha:1", "fmt": "png", "src": "a.png"},
"d2": {"kind": "local", "asset_ref": "sha:1", "fmt": "png", "src": "a.png"},
},
"md_assets": {"sha:1": {"suggested_name": "a.png", "data": b"BYTES"}},
}
ir = _normalize(blocks, meta)
assert len(ir.blocks[0].drawings) == 2
assert all(d.asset_ref == "sha:1" for d in ir.blocks[0].drawings)
# One shared on-disk asset, carried as bytes for the writer to materialize.
assert len(ir.assets) == 1
assert ir.assets[0].ref == "sha:1"
assert ir.assets[0].source == b"BYTES"
def test_external_drawing_uses_path_override_and_no_asset():
blocks = [
{
"heading": "H",
"level": 1,
"parent_headings": [],
"content": drawing_marker("d1"),
}
]
meta = {
"md_drawings": {
"d1": {
"kind": "external",
"url": "http://x/y.png",
"fmt": "png",
"src": "http://x/y.png",
}
}
}
ir = _normalize(blocks, meta)
(drawing,) = ir.blocks[0].drawings
assert drawing.path_override == "http://x/y.png"
assert drawing.asset_ref == ""
assert ir.assets == []
def test_doc_title_from_first_h1_else_filename():
ir = _normalize(
[{"heading": "First", "level": 1, "parent_headings": [], "content": "# First"}],
{},
)
assert ir.doc_title == "First"
ir2 = _normalize(
[{"heading": "Sub", "level": 2, "parent_headings": [], "content": "## Sub"}],
{},
document_name="myfile.md",
)
assert ir2.doc_title == "myfile"
def test_positions_use_heading_anchor():
ir = _normalize(
[{"heading": "Sec", "level": 2, "parent_headings": [], "content": "## Sec"}],
{},
)
(pos,) = ir.blocks[0].positions
assert pos.type == "heading"
assert pos.anchor == "Sec"
def test_document_format_from_suffix():
ir = _normalize(
[{"heading": "X", "level": 1, "parent_headings": [], "content": "# X"}],
{},
document_name="a.textpack",
)
assert ir.document_format == "textpack"
+446
View File
@@ -0,0 +1,446 @@
"""Tests for ``NativeMarkdownParser`` extract/resolver behaviour.
Covers base64 decoding, ``.textpack`` extraction + file-reference resolution
with a traversal guard, zip-bomb rejection, and the SSRF-guarded remote image
download (monkeypatched ``urllib``), including the env-controlled failure
policy.
"""
from __future__ import annotations
import io
import zipfile
from pathlib import Path
import pytest
from lightrag.parser.markdown import parser as md_parser
from lightrag.parser.markdown.parser import (
NativeMarkdownParser,
_host_is_public,
_image_bytes_and_ext,
_image_ext_from_magic,
_looks_like_svg,
_pin_socket,
)
# A 1x1 transparent PNG.
_PNG_BYTES = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06"
b"\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05"
b"\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
)
_PNG_B64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
def _make_parser() -> NativeMarkdownParser:
return NativeMarkdownParser()
_SVG_BYTES = (
b'<?xml version="1.0"?>\n'
b'<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4">'
b'<rect width="4" height="4" fill="red"/></svg>'
)
def test_magic_detection():
assert _image_ext_from_magic(_PNG_BYTES) == "png"
assert _image_ext_from_magic(b"\xff\xd8\xff\xe0junk") == "jpg"
assert _image_ext_from_magic(b"not an image") is None
# SVG is text, not a raster magic — recognised only by the SVG sniffer.
assert _image_ext_from_magic(_SVG_BYTES) is None
def test_looks_like_svg():
assert _looks_like_svg(_SVG_BYTES) is True
assert _looks_like_svg(b"\xef\xbb\xbf \n<svg></svg>") is True # BOM + space
assert _looks_like_svg(_PNG_BYTES) is False
assert _looks_like_svg(b"<html><body>no svg</body></html>") is False
def test_svg_rasterized_to_png_via_coerce():
coerced = _image_bytes_and_ext(
_SVG_BYTES, max_bytes=25 * 1024 * 1024, max_svg_pixels=16_000_000
)
assert coerced is not None
data, ext = coerced
assert ext == "png"
assert data.startswith(b"\x89PNG")
def test_svg_coerce_skips_when_rasterization_fails(monkeypatch):
# cairosvg unavailable / render failure -> coerce returns None (caller skips).
monkeypatch.setattr(md_parser, "_rasterize_svg", lambda raw, **kw: None)
assert (
_image_bytes_and_ext(_SVG_BYTES, max_bytes=25 * 1024 * 1024, max_svg_pixels=1)
is None
)
def test_svg_coerce_skips_when_rasterized_png_too_large(monkeypatch):
monkeypatch.setattr(md_parser, "_rasterize_svg", lambda raw, **kw: _PNG_BYTES)
assert (
_image_bytes_and_ext(_SVG_BYTES, max_bytes=4, max_svg_pixels=16_000_000) is None
)
def test_svg_dimensions_parsed_from_width_height_and_viewbox():
assert md_parser._svg_pixel_dimensions(_SVG_BYTES) == (4, 4)
# Unit conversion: 1in = 96px, 2in = 192px.
svg_in = b'<svg xmlns="http://www.w3.org/2000/svg" width="1in" height="2in"/>'
assert md_parser._svg_pixel_dimensions(svg_in) == (96, 192)
# width/height missing or relative (%) -> fall back to viewBox w/h.
svg_vb = (
b'<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 30 20"/>'
)
assert md_parser._svg_pixel_dimensions(svg_vb) == (30, 20)
# Neither resolvable -> None (skipped by the rasterizer).
svg_none = b'<svg xmlns="http://www.w3.org/2000/svg"/>'
assert md_parser._svg_pixel_dimensions(svg_none) is None
def test_svg_oversized_canvas_rejected_before_render(monkeypatch):
# A tiny SVG declaring a huge canvas is rejected on the pre-render pixel
# budget — cairosvg.svg2png must never be reached.
import cairosvg
def _boom(*a, **k): # pragma: no cover - must not be called
raise AssertionError("cairosvg.svg2png should not run for oversized canvas")
monkeypatch.setattr(cairosvg, "svg2png", _boom)
big = b'<svg xmlns="http://www.w3.org/2000/svg" width="100000" height="100000"/>'
assert md_parser._rasterize_svg(big, max_pixels=16_000_000) is None
def test_base64_svg_decoded_and_rasterized():
import base64 as _b64
p = _make_parser()
b64 = _b64.b64encode(_SVG_BYTES).decode()
md = f"# H\n\n![x](data:image/svg+xml;base64,{b64})\n"
_, warnings, meta = p._extract_text(md, bundle_root=None)
(asset,) = meta["md_assets"].values()
assert asset["fmt"] == "png"
assert asset["data"].startswith(b"\x89PNG")
(drawing,) = meta["md_drawings"].values()
assert drawing["kind"] == "local"
assert not warnings
def test_textpack_svg_file_rasterized_to_png(tmp_path: Path):
pack = tmp_path / "note.textpack"
with zipfile.ZipFile(pack, "w") as zf:
zf.writestr("text.markdown", "# N\n\n![logo](assets/logo.svg)\n")
zf.writestr("assets/logo.svg", _SVG_BYTES)
p = _make_parser()
_, warnings, meta = p.extract(
pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="note"
)
(asset,) = meta["md_assets"].values()
assert asset["fmt"] == "png"
assert asset["data"].startswith(b"\x89PNG")
# The on-disk name takes the rasterized extension.
assert asset["suggested_name"] == "logo.png"
assert not warnings
def test_host_is_public_rejects_internal(monkeypatch):
monkeypatch.delenv("NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS", raising=False)
assert _host_is_public("8.8.8.8") is True
assert _host_is_public("127.0.0.1") is False
assert _host_is_public("10.0.0.1") is False
assert _host_is_public("169.254.0.1") is False
assert _host_is_public("::1") is False
# CGNAT 100.64.0.0/10 is not private/reserved in Python's flags but is not
# globally routable — default-deny via is_global must still reject it.
assert _host_is_public("100.64.0.1") is False
# TEST-NET (192.0.2.0/24) is likewise non-global.
assert _host_is_public("192.0.2.1") is False
def test_allowlist_permits_configured_non_public(monkeypatch):
monkeypatch.setenv("NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS", "100.64.0.0/10")
assert _host_is_public("100.64.0.1") is True
# An address outside the allowlist is still rejected.
assert _host_is_public("10.0.0.1") is False
def test_pin_socket_refuses_internal_host(monkeypatch):
# The connection pins to the same resolution that authorised the host, so
# an internal target cannot be reached even if it would resolve.
monkeypatch.delenv("NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS", raising=False)
with pytest.raises(OSError, match="non-public host"):
_pin_socket("127.0.0.1", 80, None, None)
def test_redirect_to_non_public_host_blocked(monkeypatch):
import email.message
monkeypatch.delenv("NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS", raising=False)
handler = md_parser._GuardedRedirectHandler()
req = md_parser.urllib.request.Request("http://example.com/a")
with pytest.raises(md_parser.urllib.error.HTTPError):
handler.redirect_request(
req, None, 302, "Found", email.message.Message(), "http://10.0.0.1/evil"
)
def test_guarded_https_handler_open_does_not_raise_attribute_error(monkeypatch):
# Regression: ``https_open`` must forward only the kwargs the stdlib handler
# actually has on this Python version. On 3.12 ``HTTPSHandler`` has no
# ``_check_hostname`` attribute, so a hard reference raised AttributeError
# and every real download silently fell back to an external link.
handler = md_parser._GuardedHTTPSHandler()
captured: dict = {}
def _fake_do_open(conn_cls, req, **kwargs):
captured["conn_cls"] = conn_cls
captured["kwargs"] = kwargs
return "ok"
monkeypatch.setattr(handler, "do_open", _fake_do_open)
req = md_parser.urllib.request.Request("https://example.com/x.png")
assert handler.https_open(req) == "ok"
assert captured["conn_cls"] is md_parser._GuardedHTTPSConnection
assert "context" in captured["kwargs"]
def test_build_guarded_opener_constructs_with_guarded_handlers():
opener = md_parser._build_guarded_opener()
handler_types = {type(h) for h in opener.handlers}
assert md_parser._GuardedHTTPSHandler in handler_types
assert md_parser._GuardedHTTPHandler in handler_types
def test_base64_image_decoded_to_asset():
p = _make_parser()
md = f"# H\n\n![x]({_PNG_B64})\n"
blocks, warnings, meta = p._extract_text(md, bundle_root=None)
assert len(meta["md_assets"]) == 1
(asset,) = meta["md_assets"].values()
assert asset["data"].startswith(b"\x89PNG")
assert asset["fmt"] == "png"
(drawing,) = meta["md_drawings"].values()
assert drawing["kind"] == "local"
assert not warnings
def test_base64_non_image_bytes_rejected():
# ``data:image/png;base64,QUJD`` decodes to b"ABC" — declared PNG but not a
# real image. Magic bytes are authoritative, so it must be skipped.
p = _make_parser()
md = "# H\n\n![x](data:image/png;base64,QUJD)\n"
_, warnings, meta = p._extract_text(md, bundle_root=None)
assert meta["md_drawings"] == {}
assert meta["md_assets"] == {}
assert warnings.get("images_skipped") == 1
def test_standalone_md_relative_image_is_skipped():
p = _make_parser()
blocks, warnings, meta = p._extract_text(
"# H\n\n![x](assets/pic.png)\n", bundle_root=None
)
assert meta["md_drawings"] == {}
assert warnings.get("images_skipped") == 1
def _write_textpack(path: Path, *, text: str, assets: dict[str, bytes]) -> None:
with zipfile.ZipFile(path, "w") as zf:
zf.writestr("text.markdown", text)
for name, data in assets.items():
zf.writestr(f"assets/{name}", data)
def test_textpack_file_reference_resolved(tmp_path: Path):
pack = tmp_path / "note.textpack"
_write_textpack(
pack,
text="# Note\n\n![pic](assets/pic.png)\n",
assets={"pic.png": _PNG_BYTES},
)
p = _make_parser()
blocks, warnings, meta = p.extract(
pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="note"
)
assert len(meta["md_assets"]) == 1
(asset,) = meta["md_assets"].values()
assert asset["data"] == _PNG_BYTES
assert asset["suggested_name"] == "pic.png"
assert not warnings
def test_textpack_image_outside_assets_dir_is_resolved(tmp_path: Path):
# Compatibility: a reference may point anywhere under the bundle root, not
# only ``assets/`` — here ``media/pic.png`` and a root-level ``cover.png``.
pack = tmp_path / "note.textpack"
with zipfile.ZipFile(pack, "w") as zf:
zf.writestr("text.markdown", "# N\n\n![a](media/pic.png)\n\n![b](cover.png)\n")
zf.writestr("media/pic.png", _PNG_BYTES)
zf.writestr("cover.png", _PNG_BYTES)
p = _make_parser()
_, warnings, meta = p.extract(
pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="note"
)
assert len(meta["md_drawings"]) == 2
assert all(d["kind"] == "local" for d in meta["md_drawings"].values())
# Same bytes → deduped to a single asset.
assert len(meta["md_assets"]) == 1
assert not warnings
def test_textpack_non_image_file_reference_is_skipped(tmp_path: Path):
pack = tmp_path / "note.textpack"
with zipfile.ZipFile(pack, "w") as zf:
zf.writestr("text.markdown", "# N\n\n![x](assets/fake.png)\n")
zf.writestr("assets/fake.png", b"this is not an image")
p = _make_parser()
_, warnings, meta = p.extract(
pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="note"
)
assert meta["md_drawings"] == {}
assert warnings.get("images_skipped") == 1
def test_textpack_oversized_image_is_skipped(tmp_path: Path, monkeypatch):
# A bundled asset larger than NATIVE_MD_IMAGE_MAX_BYTES is skipped before it
# is read into memory (the per-file cap, separate from the zip-bomb total).
monkeypatch.setenv("NATIVE_MD_IMAGE_MAX_BYTES", "16")
pack = tmp_path / "big.textpack"
with zipfile.ZipFile(pack, "w") as zf:
zf.writestr("text.markdown", "# N\n\n![x](assets/big.png)\n")
zf.writestr("assets/big.png", _PNG_BYTES) # well over 16 bytes
p = _make_parser()
_, warnings, meta = p.extract(
pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="big"
)
assert meta["md_drawings"] == {}
assert meta["md_assets"] == {}
assert warnings.get("images_skipped") == 1
def test_textpack_traversal_reference_is_skipped(tmp_path: Path):
pack = tmp_path / "evil.textpack"
_write_textpack(
pack,
text="# Evil\n\n![x](../../../etc/passwd)\n",
assets={"pic.png": _PNG_BYTES},
)
p = _make_parser()
_, warnings, meta = p.extract(
pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="evil"
)
assert meta["md_drawings"] == {}
assert warnings.get("images_skipped") == 1
def test_textpack_zip_bomb_entry_count_rejected(tmp_path: Path, monkeypatch):
monkeypatch.setattr(md_parser, "_TEXTPACK_MAX_ENTRIES", 3)
pack = tmp_path / "bomb.textpack"
with zipfile.ZipFile(pack, "w") as zf:
zf.writestr("text.markdown", "# H\n")
for i in range(10):
zf.writestr(f"assets/f{i}.bin", b"x")
p = _make_parser()
with pytest.raises(RuntimeError, match="entries"):
p.extract(pack, parsed_dir=tmp_path, asset_dir=tmp_path, base_name="bomb")
# --- remote download (monkeypatched urllib) --------------------------------
class _FakeHeaders:
def __init__(self, content_type: str) -> None:
self._ct = content_type
def get_content_type(self) -> str:
return self._ct
class _FakeResponse(io.BytesIO):
def __init__(self, data: bytes, content_type: str) -> None:
super().__init__(data)
self.headers = _FakeHeaders(content_type)
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
def _patch_download(
monkeypatch, *, data=_PNG_BYTES, content_type="image/png", host_public=True
):
monkeypatch.setattr(md_parser, "_host_is_public", lambda host: host_public)
class _Opener:
def open(self, req, timeout=None):
return _FakeResponse(data, content_type)
monkeypatch.setattr(
md_parser.urllib.request, "build_opener", lambda *a, **k: _Opener()
)
def test_remote_image_dropped_when_download_disabled(monkeypatch):
# With downloading explicitly disabled an external image is dropped entirely
# — no drawing, no asset — so a doc whose only image is an external link
# produces no drawings.json. (Downloading is ON by default.)
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", "false")
p = _make_parser()
_, warnings, meta = p._extract_text(
"# H\n\n![x](http://host/y.png)\n", bundle_root=None
)
assert meta["md_drawings"] == {}
assert meta["md_assets"] == {}
assert warnings.get("images_external_dropped") == 1
def test_remote_image_downloaded_when_enabled(monkeypatch):
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", "true")
_patch_download(monkeypatch)
p = _make_parser()
_, warnings, meta = p._extract_text(
"# H\n\n![x](http://host/y.png)\n", bundle_root=None
)
(drawing,) = meta["md_drawings"].values()
assert drawing["kind"] == "local"
(asset,) = meta["md_assets"].values()
assert asset["data"] == _PNG_BYTES
def test_remote_image_failure_kept_external_by_default(monkeypatch):
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", "true")
monkeypatch.delenv("NATIVE_MD_IMAGE_DOWNLOAD_REQUIRED", raising=False)
_patch_download(monkeypatch, content_type="text/html") # explicit non-image
p = _make_parser()
_, warnings, meta = p._extract_text(
"# H\n\n![x](http://host/y.png)\n", bundle_root=None
)
(drawing,) = meta["md_drawings"].values()
assert drawing["kind"] == "external"
assert warnings.get("images_download_failed") == 1
def test_remote_image_failure_raises_when_required(monkeypatch):
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", "true")
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_REQUIRED", "true")
_patch_download(monkeypatch, content_type="text/html")
p = _make_parser()
with pytest.raises(Exception):
p._extract_text("# H\n\n![x](http://host/y.png)\n", bundle_root=None)
def test_remote_image_private_host_blocked(monkeypatch):
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", "true")
monkeypatch.delenv("NATIVE_MD_IMAGE_DOWNLOAD_REQUIRED", raising=False)
_patch_download(monkeypatch, host_public=False)
p = _make_parser()
_, warnings, meta = p._extract_text(
"# H\n\n![x](http://169.254.0.1/y.png)\n", bundle_root=None
)
(drawing,) = meta["md_drawings"].values()
assert drawing["kind"] == "external"
assert warnings.get("images_download_failed") == 1
+214
View File
@@ -0,0 +1,214 @@
"""Tests for the native-markdown downloaded-image cache (`.native_raw/`).
The cache lets a re-parse of an unchanged file reuse already-downloaded images
instead of hitting the network. ``_download`` is monkeypatched so a cache hit is
proven by it NOT being called (it raises) while assets still materialize.
"""
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
from lightrag.parser.markdown import parser as md_parser
from lightrag.parser.markdown.parser import NativeMarkdownParser
# A 1x1 transparent PNG.
_PNG_BYTES = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06"
b"\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05"
b"\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
)
_URL = "http://host/y.png"
_MD = f"# H\n\n![x]({_URL})\n"
@pytest.fixture(autouse=True)
def _enable_download(monkeypatch):
monkeypatch.setenv("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", "true")
monkeypatch.delenv("LIGHTRAG_FORCE_REPARSE_NATIVE", raising=False)
monkeypatch.delenv("NATIVE_MD_IMAGE_MAX_BYTES", raising=False)
monkeypatch.delenv("NATIVE_MD_IMAGE_MAX_SVG_PIXELS", raising=False)
monkeypatch.delenv("NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS", raising=False)
def _patch_download(monkeypatch, payload=(_PNG_BYTES, "png")):
"""Patch ``_download`` to count calls and return fixed bytes."""
counter = {"n": 0}
def _fake(self, src):
counter["n"] += 1
return payload
monkeypatch.setattr(md_parser._MarkdownImageResolver, "_download", _fake)
return counter
def _forbid_download(monkeypatch):
"""Patch ``_download`` to fail the test if the network is touched."""
def _boom(self, src): # pragma: no cover - must not be called on a cache hit
raise AssertionError("network download must not run on a cache hit")
monkeypatch.setattr(md_parser._MarkdownImageResolver, "_download", _boom)
def _make_doc(tmp_path: Path, text: str = _MD) -> tuple[Path, Path]:
src = tmp_path / "doc.md"
src.write_text(text)
parsed = tmp_path / "__parsed__" / "doc.md.parsed"
parsed.mkdir(parents=True)
(parsed / "doc.blocks.assets").mkdir()
return src, parsed
def _extract(p: NativeMarkdownParser, src: Path, parsed: Path):
return p.extract(
src, parsed_dir=parsed, asset_dir=parsed / "doc.blocks.assets", base_name="doc"
)
def _wipe_parsed(parsed: Path) -> None:
"""Mirror NativeParserBase.parse's rmtree(parsed_dir) before a re-extract."""
shutil.rmtree(parsed)
parsed.mkdir(parents=True)
(parsed / "doc.blocks.assets").mkdir()
def _raw_dir(tmp_path: Path) -> Path:
return tmp_path / "__parsed__" / "doc.md.native_raw"
def test_first_parse_downloads_and_writes_bundle(tmp_path, monkeypatch):
counter = _patch_download(monkeypatch)
src, parsed = _make_doc(tmp_path)
_, _, meta = _extract(NativeMarkdownParser(), src, parsed)
assert counter["n"] == 1
assert len(meta["md_assets"]) == 1
raw = _raw_dir(tmp_path)
assert (raw / "_manifest.json").is_file()
# Exactly one cached image file alongside the manifest.
files = sorted(c.name for c in raw.iterdir())
assert len(files) == 2 and "_manifest.json" in files
def test_reparse_unchanged_source_is_cache_hit(tmp_path, monkeypatch):
_patch_download(monkeypatch)
p = NativeMarkdownParser()
src, parsed = _make_doc(tmp_path)
_extract(p, src, parsed) # populate cache
_wipe_parsed(parsed) # base parser would rmtree the .parsed dir
_forbid_download(monkeypatch) # any download now fails the test
_, warnings, meta = _extract(p, src, parsed)
assert warnings.get("images_cache_hit") == 1
assert len(meta["md_assets"]) == 1
(asset,) = meta["md_assets"].values()
assert asset["data"] == _PNG_BYTES
def test_pure_cache_hit_does_not_touch_bundle(tmp_path, monkeypatch):
# A pure hit must write nothing: the manifest + image mtimes stay frozen, so
# on-disk timestamps alone reveal whether the cache was hit.
_patch_download(monkeypatch)
p = NativeMarkdownParser()
src, parsed = _make_doc(tmp_path)
_extract(p, src, parsed)
raw = _raw_dir(tmp_path)
before = {c.name: c.stat().st_mtime_ns for c in raw.iterdir()}
_wipe_parsed(parsed)
_forbid_download(monkeypatch) # pure hit: no download allowed
_, warnings, _ = _extract(p, src, parsed)
assert warnings.get("images_cache_hit") == 1
after = {c.name: c.stat().st_mtime_ns for c in raw.iterdir()}
assert after == before # nothing rewritten
def test_source_change_invalidates_cache(tmp_path, monkeypatch):
counter = _patch_download(monkeypatch)
p = NativeMarkdownParser()
src, parsed = _make_doc(tmp_path)
_extract(p, src, parsed)
assert counter["n"] == 1
src.write_text("# Changed\n\n![x](http://host/y.png)\n")
_wipe_parsed(parsed)
_extract(p, src, parsed)
assert counter["n"] == 2 # re-downloaded
def test_options_signature_change_invalidates_cache(tmp_path, monkeypatch):
counter = _patch_download(monkeypatch)
p = NativeMarkdownParser()
src, parsed = _make_doc(tmp_path)
_extract(p, src, parsed)
assert counter["n"] == 1
# Changing a byte-affecting knob busts the bundle.
monkeypatch.setenv("NATIVE_MD_IMAGE_MAX_BYTES", "12345")
_wipe_parsed(parsed)
_extract(p, src, parsed)
assert counter["n"] == 2
def test_force_reparse_discards_cache(tmp_path, monkeypatch):
counter = _patch_download(monkeypatch)
p = NativeMarkdownParser()
src, parsed = _make_doc(tmp_path)
_extract(p, src, parsed)
assert counter["n"] == 1
monkeypatch.setenv("LIGHTRAG_FORCE_REPARSE_NATIVE", "true")
_wipe_parsed(parsed)
_extract(p, src, parsed)
assert counter["n"] == 2
def test_tampered_cache_file_falls_back_to_download(tmp_path, monkeypatch):
counter = _patch_download(monkeypatch)
p = NativeMarkdownParser()
src, parsed = _make_doc(tmp_path)
_extract(p, src, parsed)
assert counter["n"] == 1
# Corrupt the cached image bytes so the sha256 no longer matches the manifest.
raw = _raw_dir(tmp_path)
img = next(c for c in raw.iterdir() if c.name != "_manifest.json")
img.write_bytes(b"corrupted")
_wipe_parsed(parsed)
_extract(p, src, parsed)
assert counter["n"] == 2 # integrity mismatch -> miss -> re-download
def test_svg_cached_as_png_and_reused_without_rasterizing(tmp_path, monkeypatch):
# _download returns the post-rasterization PNG, so a cache hit reuses PNG
# bytes and never calls cairosvg.
_patch_download(monkeypatch, payload=(_PNG_BYTES, "png"))
p = NativeMarkdownParser()
src, parsed = _make_doc(tmp_path, text=f"# H\n\n![s]({_URL})\n")
_, _, meta = _extract(p, src, parsed)
(asset,) = meta["md_assets"].values()
assert asset["fmt"] == "png" and asset["data"].startswith(b"\x89PNG")
_wipe_parsed(parsed)
_forbid_download(monkeypatch)
import cairosvg
def _boom(*a, **k): # pragma: no cover - must not run on a cache hit
raise AssertionError("cairosvg must not run on a cache hit")
monkeypatch.setattr(cairosvg, "svg2png", _boom)
_, warnings, meta = _extract(p, src, parsed)
assert warnings.get("images_cache_hit") == 1
def test_native_raw_is_sibling_and_survives_parsed_rmtree(tmp_path, monkeypatch):
_patch_download(monkeypatch)
src, parsed = _make_doc(tmp_path)
_extract(NativeMarkdownParser(), src, parsed)
raw = _raw_dir(tmp_path)
assert raw.is_dir()
assert raw.parent == parsed.parent # sibling of .parsed, not nested
shutil.rmtree(parsed) # base parser wipes parsed_dir
assert raw.is_dir() # cache survives
+81
View File
@@ -0,0 +1,81 @@
"""Routing + registry wiring for the native markdown engine.
``parser_rules=""`` is passed explicitly so these assertions are independent of
any ambient ``LIGHTRAG_PARSER`` in the dev environment.
"""
from __future__ import annotations
import asyncio
from lightrag.parser import registry
from lightrag.parser.native_dispatch import NativeParser
from lightrag.parser.routing import resolve_file_parser_directives
def _engine(path: str, rules: str = "") -> str:
return resolve_file_parser_directives(path, parser_rules=rules)[0]
def test_native_declares_markdown_suffixes():
assert registry.suffix_capabilities("native") == frozenset(
{"docx", "md", "textpack"}
)
def test_textpack_and_md_in_upload_allowlist():
suffixes = registry.available_engine_suffixes()
assert "textpack" in suffixes
assert "md" in suffixes
def test_get_parser_native_is_dispatcher():
parser = registry.get_parser("native")
assert type(parser).__name__ == "NativeParser"
def test_textpack_defaults_to_native_without_hint():
assert _engine("note.textpack") == "native"
def test_md_defaults_to_legacy_opt_in_for_native():
assert _engine("doc.md") == "legacy"
assert _engine("doc.[native].md") == "native"
assert _engine("doc.md", rules="md:native") == "native"
def test_docx_default_unchanged():
assert _engine("report.docx") == "legacy"
def test_textpack_falls_back_to_native_when_rule_engine_cannot_handle_it():
# legacy cannot parse .textpack; the per-suffix default still wins.
assert _engine("note.textpack", rules="textpack:legacy") == "native"
class _SentinelParser:
def __init__(self, name: str) -> None:
self.name = name
async def parse(self, ctx):
return self.name
class _Ctx:
def __init__(self, file_path: str) -> None:
self.file_path = file_path
def _dispatch(file_path: str) -> str:
dispatcher = NativeParser()
dispatcher._docx = _SentinelParser("docx")
dispatcher._markdown = _SentinelParser("markdown")
return asyncio.run(dispatcher.parse(_Ctx(file_path)))
def test_dispatcher_routes_by_suffix():
assert _dispatch("a.md") == "markdown"
assert _dispatch("a.textpack") == "markdown"
assert _dispatch("a.[native-iet].md") == "markdown"
assert _dispatch("a.docx") == "docx"
assert _dispatch("report.[native].docx") == "docx"