"""Tests for per-file ENGINE parameters (Phase 2). Engine params attach to the engine token of a hint / LIGHTRAG_PARSER rule (``mineru(page_range=1-3,language=en)`` / ``docling(force_ocr=true)``) and ride the existing ``parse_engine`` field encoded in hint syntax. The suite's conftest strips ``MINERU_API_MODE`` (tests default to local, which allows only a single page_range segment), so multi-segment cases explicitly set official mode via monkeypatch. ``parser_rules=""`` keeps assertions independent of any ambient ``LIGHTRAG_PARSER``. """ from __future__ import annotations import importlib import sys import pytest from lightrag.parser.param_schema import ( normalize_engine_params, parse_engine_params, render_engine_params, ) from lightrag.parser.routing import ( FilenameParserHintError, ParserRoutingConfigError, decode_parse_engine, encode_parse_engine, normalize_parser_engine, resolve_file_parser_directives, resolve_parser_directives, validate_parser_routing_config, ) # Importing document_routes runs argparse over sys.argv at import time; # neutralise pytest's argv during that first import. _original_argv = sys.argv[:] sys.argv = [sys.argv[0]] importlib.import_module("lightrag.api.routers.document_routes") sys.argv = _original_argv # --------------------------------------------------------------------------- # # parse_engine_params # --------------------------------------------------------------------------- # def test_parse_engine_params_basic_and_alias(): parsed, errors = parse_engine_params( "page_range=1-3,language=en", engine="mineru", label="x" ) assert errors == [] assert parsed == {"page_range": "1-3", "language": "en"} # alias pr -> page_range parsed, errors = parse_engine_params("pr=2-4", engine="mineru", label="x") assert errors == [] and parsed == {"page_range": "2-4"} # alias local_pm -> local_parse_method parsed, errors = parse_engine_params("local_pm=ocr", engine="mineru", label="x") assert errors == [] and parsed == {"local_parse_method": "ocr"} def test_parse_engine_params_bool_coercion(): parsed, errors = parse_engine_params("force_ocr=false", engine="docling", label="x") assert errors == [] and parsed == {"force_ocr": False} parsed, errors = parse_engine_params("ocr=yes", engine="docling", label="x") assert errors == [] and parsed == {"force_ocr": True} def test_parse_engine_params_multi_segment_official(monkeypatch): monkeypatch.setenv("MINERU_API_MODE", "official") parsed, errors = parse_engine_params( "page_range=1-3,page_range=5,page_range=7-9", engine="mineru", label="x" ) assert errors == [] and parsed == {"page_range": "1-3,5,7-9"} def test_parse_engine_params_multi_segment_rejected_local(monkeypatch): monkeypatch.delenv("MINERU_API_MODE", raising=False) # default local parsed, errors = parse_engine_params( "page_range=1-3,page_range=5", engine="mineru", label="x" ) assert parsed == {} or "page_range" not in parsed assert errors and "local supports only a single page" in errors[0] def test_parse_engine_params_local_parse_method_accepted_local(monkeypatch): monkeypatch.delenv("MINERU_API_MODE", raising=False) # default local parsed, errors = parse_engine_params( "local_parse_method=ocr", engine="mineru", label="x" ) assert errors == [] and parsed == {"local_parse_method": "ocr"} def test_parse_engine_params_local_parse_method_rejected_official(monkeypatch): # local_parse_method is local-only: official neither sends it nor folds it # into the cache key, so accepting it would persist a silent no-op directive. monkeypatch.setenv("MINERU_API_MODE", "official") parsed, errors = parse_engine_params( "local_parse_method=ocr", engine="mineru", label="x" ) assert "local_parse_method" not in parsed assert errors and "MINERU_API_MODE=local" in errors[0] @pytest.mark.parametrize( "block,engine,needle", [ ("page_range=1", "legacy", "does not accept parameters"), ("page_range=1", "native", "does not accept parameters"), ("foo=1", "mineru", "unknown parameter 'foo'"), ("local_parse_method=bad", "mineru", "must be one of"), ("force_ocr=maybe", "docling", "must be a boolean"), ("language=", "mineru", "must be non-empty"), ("page_range=1-3,5", "mineru", "page lists must repeat the key"), ], ) def test_parse_engine_params_errors(block, engine, needle): _parsed, errors = parse_engine_params(block, engine=engine, label="x") assert errors and any(needle in e for e in errors) # --------------------------------------------------------------------------- # # normalize_engine_params (resolved-dict path) + encode/decode round-trip # --------------------------------------------------------------------------- # def test_normalize_engine_params_coerces_bool_string(): # A direct caller may pass force_ocr as the string "false" — must coerce. norm, errors = normalize_engine_params("docling", {"force_ocr": "false"}) assert errors == [] and norm == {"force_ocr": False} def test_normalize_engine_params_accepts_page_range_list_or_string(monkeypatch): monkeypatch.setenv("MINERU_API_MODE", "official") by_list, e1 = normalize_engine_params("mineru", {"page_range": ["1-3", "5"]}) by_str, e2 = normalize_engine_params("mineru", {"page_range": "1-3,5"}) assert e1 == [] and e2 == [] and by_list == by_str == {"page_range": "1-3,5"} def test_normalize_engine_params_rejects_unregistered_engine(): _norm, errors = normalize_engine_params("legacy", {"page_range": "1"}) assert errors and "does not accept parameters" in errors[0] def test_encode_decode_round_trip(monkeypatch): monkeypatch.setenv("MINERU_API_MODE", "official") enc = encode_parse_engine("mineru", {"page_range": "1-3,5", "language": "en"}) # list param emitted as repeated keys; deterministic (sorted) assert enc == "mineru(language=en,page_range=1-3,page_range=5)" engine, params, errors = decode_parse_engine(enc) assert engine == "mineru" and errors == [] assert params == {"page_range": "1-3,5", "language": "en"} def test_encode_bare_when_no_params(): assert encode_parse_engine("mineru", {}) == "mineru" assert encode_parse_engine("legacy", None) == "legacy" def test_render_engine_params_bool_and_list(monkeypatch): monkeypatch.setenv("MINERU_API_MODE", "official") inner, errs = render_engine_params("mineru", {"page_range": "1-3,5"}) assert errs == [] and inner == "page_range=1-3,page_range=5" inner, errs = render_engine_params("docling", {"force_ocr": False}) assert errs == [] and inner == "force_ocr=false" def test_normalize_parser_engine_strips_params(): assert normalize_parser_engine("mineru(page_range=1-3)") == "mineru" assert normalize_parser_engine("MINERU(page_range=1-3,language=en)") == "mineru" assert normalize_parser_engine("mineru") == "mineru" def test_decode_malformed_reports_errors(): _e, _p, errors = decode_parse_engine("mineru(page_range=") assert errors and "unbalanced" in errors[0] _e, _p, errors = decode_parse_engine("mineru(badkey=1)") assert errors and "unknown parameter" in errors[0] # --------------------------------------------------------------------------- # # resolve_parser_directives — filename hint + rule + overlay + drop # --------------------------------------------------------------------------- # def test_resolve_filename_engine_params(monkeypatch): monkeypatch.delenv("MINERU_API_MODE", raising=False) # local single segment d = resolve_parser_directives( "paper.[mineru(page_range=1-3,language=en)].pdf", parser_rules="", require_external_endpoint=False, ) assert d.engine == "mineru" assert d.engine_params == {"page_range": "1-3", "language": "en"} # selector stays a pure (here empty) string assert "(" not in d.process_options def test_resolve_rule_engine_params(): d = resolve_parser_directives( "a.pdf", parser_rules="pdf:mineru(language=en)", require_external_endpoint=False, ) assert d.engine == "mineru" and d.engine_params == {"language": "en"} def test_resolve_drop_rule_params_when_hint_engine_wins(): # Rule names mineru(language); filename hint names a different usable engine # (docling) which wins -> mineru's engine params are dropped. d = resolve_parser_directives( "paper.[docling(force_ocr=true)].pdf", parser_rules="pdf:mineru(language=en)", require_external_endpoint=False, ) assert d.engine == "docling" assert d.engine_params == {"force_ocr": True} @pytest.mark.parametrize( "name", [ "x.[mineru(language=)].pdf", # empty value "x.[mineru(local_parse_method=bad)].pdf", # bad enum "x.[docling(force_ocr=maybe)].pdf", # bad bool "x.[legacy(page_range=1)].txt", # engine declares no params "x.[mineru(page_range=1-3,5)].pdf", # env-style comma list ], ) def test_upload_validation_raises_on_bad_engine_params(name): with pytest.raises(FilenameParserHintError): resolve_file_parser_directives( name, parser_rules="", require_external_endpoint=False ) # --------------------------------------------------------------------------- # # validate_parser_routing_config (startup) # --------------------------------------------------------------------------- # def test_validate_rule_engine_params_ok(monkeypatch): # mineru requires its endpoint configured before its rule validates. monkeypatch.setenv("MINERU_API_MODE", "local") monkeypatch.setenv("MINERU_LOCAL_ENDPOINT", "http://127.0.0.1:8000") validate_parser_routing_config("pdf:mineru(language=en);*:legacy-R") @pytest.mark.parametrize( "rules,needle", [ ("pdf:legacy(page_range=1)", "does not accept parameters"), ("pdf:mineru(foo=1)", "unknown parameter 'foo'"), ("pdf:mineru(local_parse_method=bad)", "must be one of"), ("pdf:docling(force_ocr=maybe)", "must be a boolean"), ], ) def test_validate_rule_engine_params_rejected(rules, needle): with pytest.raises(ParserRoutingConfigError, match=needle): validate_parser_routing_config(rules) # --------------------------------------------------------------------------- # # Direct-API guard via apipeline_enqueue_documents -> _parse_engine_at # --------------------------------------------------------------------------- # def _decode_via_parse_engine_at(raw: str): """Exercise the same decode+validate+re-encode that _parse_engine_at runs.""" engine, params, errs = decode_parse_engine(raw) if errs: raise ValueError("; ".join(errs)) return encode_parse_engine(engine, params) if params else engine def test_direct_parse_engine_field_canonicalises(monkeypatch): monkeypatch.setenv("MINERU_API_MODE", "official") assert ( _decode_via_parse_engine_at("mineru(page_range=1-3,page_range=5)") == "mineru(page_range=1-3,page_range=5)" ) @pytest.mark.parametrize( "raw", [ "legacy(page_range=1)", # legacy declares no params "mineru(badkey=1)", # unknown "mineru(page_range=", # malformed ], ) def test_direct_parse_engine_field_rejected(raw): with pytest.raises(ValueError): _decode_via_parse_engine_at(raw)