diff --git a/README.md b/README.md index 37be2d1d0..a29cc6e1e 100644 --- a/README.md +++ b/README.md @@ -135,12 +135,20 @@ The `markitdown-ocr` plugin adds OCR support to PDF, DOCX, PPTX, and XLSX conver ```bash pip install markitdown-ocr -pip install openai # or any OpenAI-compatible client +pip install openai ``` **Usage:** -Pass the same `llm_client` and `llm_model` you would use for image descriptions: +```bash +markitdown document.pdf --use-plugins --llm-client openai --llm-model gpt-4o +``` + +The CLI creates an `openai.OpenAI()` client only when both LLM options are supplied. +Install it with `pip install openai` and set `OPENAI_API_KEY`. + +Or use the Python API and pass the same `llm_client` and `llm_model` you would use +for image descriptions: ```python from markitdown import MarkItDown diff --git a/packages/markitdown/src/markitdown/__main__.py b/packages/markitdown/src/markitdown/__main__.py index 56eb89cd8..7ad283d57 100644 --- a/packages/markitdown/src/markitdown/__main__.py +++ b/packages/markitdown/src/markitdown/__main__.py @@ -126,6 +126,17 @@ def main(): help="Use 3rd-party plugins to convert files. Use --list-plugins to see installed plugins.", ) + parser.add_argument( + "--llm-client", + choices=("openai",), + help="LLM client to use. Currently supports: openai.", + ) + + parser.add_argument( + "--llm-model", + help="LLM model name. Required when using --llm-client.", + ) + parser.add_argument( "--list-plugins", action="store_true", @@ -200,6 +211,8 @@ def main(): ) sys.exit(0) + llm_kwargs = _create_llm_kwargs(parser, args) + if args.use_docintel: if args.endpoint is None: _exit_with_error( @@ -209,7 +222,9 @@ def main(): _exit_with_error("Filename is required when using Document Intelligence.") markitdown = MarkItDown( - enable_plugins=args.use_plugins, docintel_endpoint=args.endpoint + enable_plugins=args.use_plugins, + docintel_endpoint=args.endpoint, + **llm_kwargs, ) elif args.use_cu: if args.cu_endpoint is None: @@ -240,9 +255,11 @@ def main(): _exit_with_error(f"Unknown file type: {name}") cu_kwargs["cu_file_types"] = cu_types - markitdown = MarkItDown(enable_plugins=args.use_plugins, **cu_kwargs) + markitdown = MarkItDown( + enable_plugins=args.use_plugins, **cu_kwargs, **llm_kwargs + ) else: - markitdown = MarkItDown(enable_plugins=args.use_plugins) + markitdown = MarkItDown(enable_plugins=args.use_plugins, **llm_kwargs) if args.filename is None: result = markitdown.convert_stream( @@ -272,6 +289,37 @@ def _handle_output(args, result: DocumentConverterResult): ) +def _create_llm_kwargs( + parser: argparse.ArgumentParser, args: argparse.Namespace +) -> Dict[str, Any]: + if args.llm_client is None and args.llm_model is None: + return {} + + if args.llm_client is None: + parser.error("--llm-model requires --llm-client.") + if args.llm_model is None or not args.llm_model.strip(): + parser.error("--llm-client requires a non-empty --llm-model.") + + try: + from openai import OpenAI, OpenAIError + except ModuleNotFoundError as error: + if error.name == "openai": + parser.error( + "The 'openai' package is required for --llm-client openai. " + "Install it with 'pip install openai'." + ) + raise + + try: + llm_client = OpenAI() + except OpenAIError: + parser.error( + "Unable to initialize the OpenAI client. Set OPENAI_API_KEY and try again." + ) + + return {"llm_client": llm_client, "llm_model": args.llm_model.strip()} + + def _exit_with_error(message: str): print(message) sys.exit(1) diff --git a/packages/markitdown/tests/test_cli_misc.py b/packages/markitdown/tests/test_cli_misc.py index cf6c9ccc7..5ed5d3cd5 100644 --- a/packages/markitdown/tests/test_cli_misc.py +++ b/packages/markitdown/tests/test_cli_misc.py @@ -1,5 +1,13 @@ #!/usr/bin/env python3 -m pytest +import builtins import subprocess +import sys +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +import markitdown.__main__ as cli from markitdown import __version__ # This file contains CLI tests that are not directly tested by the FileTestVectors. @@ -27,6 +35,208 @@ def test_invalid_flag() -> None: assert "SYNTAX" in result.stderr, "Expected 'SYNTAX' to appear in STDERR" +@pytest.mark.parametrize( + ("arguments", "expected_kwargs"), + [ + (["input.pdf", "--use-plugins"], {"enable_plugins": True}), + ( + [ + "input.pdf", + "--use-plugins", + "--use-docintel", + "--endpoint", + "https://example.cognitiveservices.azure.com", + ], + { + "enable_plugins": True, + "docintel_endpoint": "https://example.cognitiveservices.azure.com", + }, + ), + ( + [ + "input.pdf", + "--use-plugins", + "--use-cu", + "--cu-endpoint", + "https://example.cognitiveservices.azure.com", + ], + { + "enable_plugins": True, + "cu_endpoint": "https://example.cognitiveservices.azure.com", + }, + ), + ], +) +def test_llm_options_are_forwarded_to_all_constructor_paths( + monkeypatch, arguments, expected_kwargs +) -> None: + class FakeOpenAIError(Exception): + pass + + openai_client = Mock() + openai_constructor = Mock(return_value=openai_client) + markitdown_constructor = Mock( + return_value=SimpleNamespace( + convert=Mock(return_value=SimpleNamespace(markdown="")) + ) + ) + monkeypatch.setitem( + sys.modules, + "openai", + SimpleNamespace( + OpenAI=openai_constructor, + OpenAIError=FakeOpenAIError, + ), + ) + monkeypatch.setattr(cli, "MarkItDown", markitdown_constructor) + monkeypatch.setattr( + sys, + "argv", + [ + "markitdown", + *arguments, + "--llm-client", + "openai", + "--llm-model", + "gpt-4o", + ], + ) + + cli.main() + + openai_constructor.assert_called_once_with() + markitdown_constructor.assert_called_once_with( + **expected_kwargs, + llm_client=openai_client, + llm_model="gpt-4o", + ) + + +@pytest.mark.parametrize( + ("arguments", "message"), + [ + (["input.pdf", "--llm-model", "gpt-4o"], "--llm-model requires --llm-client."), + ( + ["input.pdf", "--llm-client", "openai"], + "--llm-client requires a non-empty --llm-model.", + ), + ( + ["input.pdf", "--llm-client", "openai", "--llm-model", ""], + "--llm-client requires a non-empty --llm-model.", + ), + ], +) +def test_llm_options_require_client_and_model( + monkeypatch, capsys, arguments, message +) -> None: + monkeypatch.setattr(sys, "argv", ["markitdown", *arguments]) + + with pytest.raises(SystemExit) as exc_info: + cli.main() + + assert exc_info.value.code == 2 + assert message in capsys.readouterr().err + + +def test_llm_client_rejects_unsupported_values(monkeypatch, capsys) -> None: + monkeypatch.setattr( + sys, "argv", ["markitdown", "input.pdf", "--llm-client", "azure-openai"] + ) + + with pytest.raises(SystemExit) as exc_info: + cli.main() + + assert exc_info.value.code == 2 + assert "invalid choice" in capsys.readouterr().err + + +def test_openai_is_imported_only_when_requested(monkeypatch) -> None: + import_module = builtins.__import__ + + def fail_if_openai_imported(name, *args, **kwargs): + if name == "openai": + raise AssertionError("openai should not be imported without --llm-client") + return import_module(name, *args, **kwargs) + + markitdown_constructor = Mock( + return_value=SimpleNamespace( + convert=Mock(return_value=SimpleNamespace(markdown="")) + ) + ) + monkeypatch.setattr(builtins, "__import__", fail_if_openai_imported) + monkeypatch.setattr(cli, "MarkItDown", markitdown_constructor) + monkeypatch.setattr(sys, "argv", ["markitdown", "input.pdf"]) + + cli.main() + + markitdown_constructor.assert_called_once_with(enable_plugins=False) + + +def test_openai_missing_package_error_is_actionable(monkeypatch, capsys) -> None: + import_module = builtins.__import__ + + def raise_missing_openai(name, *args, **kwargs): + if name == "openai": + raise ModuleNotFoundError("No module named 'openai'", name="openai") + return import_module(name, *args, **kwargs) + + monkeypatch.delitem(sys.modules, "openai", raising=False) + monkeypatch.setattr(builtins, "__import__", raise_missing_openai) + monkeypatch.setattr( + sys, + "argv", + [ + "markitdown", + "input.pdf", + "--llm-client", + "openai", + "--llm-model", + "gpt-4o", + ], + ) + + with pytest.raises(SystemExit) as exc_info: + cli.main() + + assert exc_info.value.code == 2 + assert "pip install openai" in capsys.readouterr().err + + +def test_openai_missing_api_key_error_is_actionable(monkeypatch, capsys) -> None: + class FakeOpenAIError(Exception): + pass + + openai_constructor = Mock( + side_effect=FakeOpenAIError("The api_key client option must be set") + ) + monkeypatch.setitem( + sys.modules, + "openai", + SimpleNamespace( + OpenAI=openai_constructor, + OpenAIError=FakeOpenAIError, + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "markitdown", + "input.pdf", + "--llm-client", + "openai", + "--llm-model", + "gpt-4o", + ], + ) + + with pytest.raises(SystemExit) as exc_info: + cli.main() + + assert exc_info.value.code == 2 + assert "Set OPENAI_API_KEY" in capsys.readouterr().err + + if __name__ == "__main__": """Runs this file's tests from the command line.""" test_version()