diff --git a/.github/workflows/_test-cli.yml b/.github/workflows/_test-cli.yml new file mode 100644 index 00000000..c9d501e4 --- /dev/null +++ b/.github/workflows/_test-cli.yml @@ -0,0 +1,74 @@ +name: Test Command Line Interface + +on: + workflow_call: + workflow_dispatch: + +env: + MINDEE_API_KEY: ${{ secrets.MINDEE_API_KEY_SE_TESTS }} + MINDEE_V2_API_KEY: ${{ secrets.MINDEE_V2_SE_TESTS_API_KEY }} + MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID: ${{ secrets.MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID }} + MINDEE_V2_SE_TESTS_CLASSIFICATION_MODEL_ID: ${{ secrets.MINDEE_V2_SE_TESTS_CLASSIFICATION_MODEL_ID }} + MINDEE_V2_SE_TESTS_CROP_MODEL_ID: ${{ secrets.MINDEE_V2_SE_TESTS_CROP_MODEL_ID }} + MINDEE_V2_SE_TESTS_SPLIT_MODEL_ID: ${{ secrets.MINDEE_V2_SE_TESTS_SPLIT_MODEL_ID }} + MINDEE_V2_SE_TESTS_OCR_MODEL_ID: ${{ secrets.MINDEE_V2_SE_TESTS_OCR_MODEL_ID }} +permissions: + contents: read + +jobs: + test: + name: Run CLI tests + timeout-minutes: 30 + strategy: + max-parallel: 4 + matrix: + os: + - "ubuntu-22.04" + - "macos-latest" + - "windows-2022" + python-version: + - "3.10" + - "3.14" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache dependencies + uses: actions/cache@v5 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-cli-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-cli- + + - name: Install dependencies + run: | + python -m pip install pip + pip install -e '.' + + - name: Test V1 CLI + shell: sh + run: | + ./tests/test_v1_cli.sh ./tests/data/file_types/pdf/blank_1.pdf python + + - name: Test V2 CLI + shell: sh + run: | + ./tests/test_v2_cli.sh ./tests/data/file_types/pdf/blank_1.pdf python + + - name: Notify Slack Action on Failure + uses: ravsamhq/notify-slack-action@2.5.0 + if: ${{ always() && github.ref_name == 'main' }} + with: + status: ${{ job.status }} + notify_when: "failure" + notification_title: "[Python] CLI test '{workflow}' is failing" + env: + SLACK_WEBHOOK_URL: ${{ secrets.PRODUCTION_ISSUES_SLACK_HOOK_URL }} diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 42ce2bb8..b5835ccf 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -4,6 +4,11 @@ on: schedule: - cron: '32 0 * * *' +permissions: + contents: read + packages: read + actions: read + jobs: test-regressions: uses: mindee/mindee-api-python/.github/workflows/_test-regressions.yml@main @@ -11,3 +16,6 @@ jobs: test-code-samples: uses: mindee/mindee-api-python/.github/workflows/_smoke-test.yml@main secrets: inherit + test-cli: + uses: mindee/mindee-api-python/.github/workflows/_test-cli.yml@main + secrets: inherit diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 79bd91f2..617b6eac 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -29,3 +29,7 @@ jobs: uses: ./.github/workflows/_smoke-test.yml needs: test-units secrets: inherit + test-cli: + uses: ./.github/workflows/_test-cli.yml + needs: test-units + secrets: inherit diff --git a/CHANGELOG.md b/CHANGELOG.md index 339a5190..1e9b7eaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Mindee Python Client Library Changelog +## Unreleased +### ¡Breaking Changes! +* :boom: :sparkles: unify the `mindee` CLI to mirror the .NET reference: V2 inference commands (`classification`, `crop`, `extraction`, `ocr`, `split`) and `search-models` at the root, V1 product commands wrapped under a `v1` subcommand +* :boom: :coffin: remove the separate `mindeeV2` script entry point; use `mindee` for both V1 (via `mindee v1 …`) and V2 +* :boom: :recycle: refactor the V2 CLI from a central `InferenceCommand` config to one class per command (`ClassificationCommand`, `CropCommand`, `ExtractionCommand`, `OcrCommand`, `SplitCommand`) extending a new `BaseInferenceCommand`. `InferenceCommand` and `InferenceCommandOptions` are no longer exported from `mindee.v2.commands`. +### Changes +* :sparkles: add `--output/-o` (`summary` / `full` / `raw`) plus per-product `--api-key/-k`, `--model-id/-m`, `--alias/-a` flags; expose extraction-only `--rag/-g`, `--raw-text/-r`, `--confidence/-c`, `--polygon/-p`, `--text-context/-t` +* :sparkles: add `ocr` subcommand to the V2 CLI +* :sparkles: add `--verbose/-v` flag on the CLI (repeat for debug-level output) +* :sparkles: `SearchResponse` now extends `CommonResponse` and exposes `raw_http` +* :white_check_mark: add `tests/test_v1_cli.sh` and `tests/test_v2_cli.sh` integration scripts and a `_test-cli.yml` workflow mirroring the .NET CLI test setup + ## v5.0.0 - 2026-06-17 ### ¡Breaking Changes! * :boom: :recycle: update V1 & V2 syntaxes to match other SDKs diff --git a/mindee/cli.py b/mindee/cli.py index 3a3f807d..e096d5bd 100644 --- a/mindee/cli.py +++ b/mindee/cli.py @@ -1,7 +1,75 @@ -from mindee.v1.commands.cli_parser import MindeeParser +import logging +import sys + +from mindee.v2.commands.cli_parser import MindeeParser + +_V1_DASHV_PRODUCTS = ("custom", "generated") + + +def _find_v1_dashv_boundary(argv: list[str]) -> int | None: + """Locate the position after which ``-v`` belongs to V1 ``custom`` / + ``generated``. + + These two V1 products register ``-v/--version``; tokens at or beyond + the returned index must be left alone by the verbose pre-scan. + """ + for i, token in enumerate(argv): + if token == "v1" and i + 1 < len(argv) and argv[i + 1] in _V1_DASHV_PRODUCTS: + return i + 2 + return None + + +def _extract_verbose_level(argv: list[str]) -> tuple[int, list[str]]: + """Pre-scan ``argv`` for ``--verbose`` / ``-v`` flags. + + Mirrors ``mindee-api-dotnet``'s ``args.Contains("--verbose")`` check: + the flag is consumed before argparse runs so it can appear anywhere + on the command line. + + * ``--verbose`` is consumed at any position (no conflict). + * ``-v`` is consumed at any position *except* after a ``v1 custom`` + or ``v1 generated`` invocation, where it is the V1 product's own + ``--version`` option. + + :returns: ``(level, remaining_argv)`` where ``level`` counts the number + of recognized verbose-flag occurrences. + """ + level = 0 + remaining: list[str] = [] + v1_dashv_start = _find_v1_dashv_boundary(argv) + for i, token in enumerate(argv): + if token == "--verbose": + level += 1 + continue + if token == "-v" and (v1_dashv_start is None or i < v1_dashv_start): + level += 1 + continue + remaining.append(token) + return level, remaining + + +def _configure_logging(verbose_level: int) -> None: + """Set the ``mindee`` logger level based on the verbose count.""" + if verbose_level <= 0: + return + target = logging.INFO if verbose_level == 1 else logging.DEBUG + logging.getLogger("mindee").setLevel(target) + logging.getLogger().setLevel(target) def main() -> None: - """Run the Command Line Interface.""" + """Run the Command Line Interface. + + The unified ``mindee`` binary exposes V2 inference commands and the + ``search-models`` utility at the root, with all V1 product commands + wrapped under a ``v1`` subcommand — mirroring the canonical + ``mindee-api-dotnet`` CLI. + + Pass ``--verbose`` (or ``-v``) to enable diagnostic logging; repeat + the flag (``--verbose --verbose``) for debug-level output. + """ + verbose_level, argv = _extract_verbose_level(sys.argv[1:]) + _configure_logging(verbose_level) + sys.argv = [sys.argv[0], *argv] parser = MindeeParser() - parser.call_parse() + sys.exit(parser.call_parse() or 0) diff --git a/mindee/logger.py b/mindee/logger.py index b3478160..64f20b22 100644 --- a/mindee/logger.py +++ b/mindee/logger.py @@ -3,7 +3,7 @@ import logging import os -LOGLEVEL = os.environ.get("MINDEE_LOGLEVEL", "INFO").upper() +LOGLEVEL = os.environ.get("MINDEE_LOGLEVEL", "WARNING").upper() logging.basicConfig(level=LOGLEVEL) logger = logging.getLogger("mindee") diff --git a/mindee/v1/commands/__init__.py b/mindee/v1/commands/__init__.py index 5d54c30c..2fe72eba 100644 --- a/mindee/v1/commands/__init__.py +++ b/mindee/v1/commands/__init__.py @@ -1,4 +1,8 @@ -from mindee.v1.commands.cli_parser import MindeeArgumentParser, MindeeParser +from mindee.v1.commands.cli_parser import ( + MindeeArgumentParser, + MindeeParser, + register_v1_product_subparsers, +) from mindee.v1.commands.cli_products import PRODUCTS, CommandConfig __all__ = [ @@ -6,4 +10,5 @@ "CommandConfig", "MindeeArgumentParser", "MindeeParser", + "register_v1_product_subparsers", ] diff --git a/mindee/v1/commands/cli_parser.py b/mindee/v1/commands/cli_parser.py index 323bb016..284c4df4 100644 --- a/mindee/v1/commands/cli_parser.py +++ b/mindee/v1/commands/cli_parser.py @@ -103,11 +103,54 @@ def add_custom_options(self) -> None: ) +def register_v1_product_subparsers(parser: ArgumentParser) -> None: + """ + Register V1 product subparsers under the given ``parser``. + + Used both by the legacy ``mindee`` binary and by the ``v1`` group of + the unified ``mindeeV2`` CLI. + """ + parse_product_subparsers = parser.add_subparsers( + dest="product_name", + required=True, + parser_class=MindeeArgumentParser, + ) + + for name, info in PRODUCTS.items(): + parse_subparser = parse_product_subparsers.add_parser(name, help=info.help) + + parse_subparser.add_main_options() + parse_subparser.add_sending_options() + parse_subparser.add_display_options() + if name in ("custom", "generated"): + parse_subparser.add_custom_options() + else: + parse_subparser.add_argument( + "-t", + "--full-text", + dest="include_words", + action="store_true", + help="include full document text in response", + ) + + if info.is_async and info.is_sync: + parse_subparser.add_argument( + "-A", + "--asynchronous", + dest="async_parse", + help="Parse asynchronously", + action="store_true", + required=False, + default=False, + ) + + class MindeeParser: """Custom parser for the Mindee CLI.""" parser: MindeeArgumentParser """Parser options.""" + parsed_args: Namespace """Stores attributes relating to parsing.""" client: Client @@ -230,39 +273,7 @@ def _doc_str(output_type: str, doc_response: Document) -> str: def _set_args(self) -> Namespace: """Parse command line arguments.""" - parse_product_subparsers = self.parser.add_subparsers( - dest="product_name", - required=True, - ) - - for name, info in PRODUCTS.items(): - parse_subparser = parse_product_subparsers.add_parser(name, help=info.help) - - parse_subparser.add_main_options() - parse_subparser.add_sending_options() - parse_subparser.add_display_options() - if name in ("custom", "generated"): - parse_subparser.add_custom_options() - else: - parse_subparser.add_argument( - "-t", - "--full-text", - dest="include_words", - action="store_true", - help="include full document text in response", - ) - - if info.is_async and info.is_sync: - parse_subparser.add_argument( - "-A", - "--asynchronous", - dest="async_parse", - help="Parse asynchronously", - action="store_true", - required=False, - default=False, - ) - + register_v1_product_subparsers(self.parser) parsed_args = self.parser.parse_args() return parsed_args diff --git a/mindee/v2/cli.py b/mindee/v2/cli.py deleted file mode 100644 index 61cfc863..00000000 --- a/mindee/v2/cli.py +++ /dev/null @@ -1,11 +0,0 @@ -from mindee.v2.commands.cli_parser import MindeeParser - - -def main() -> None: - """Run the Command Line Interface.""" - parser = MindeeParser() - parser.call_parse() - - -if __name__ == "__main__": - main() diff --git a/mindee/v2/commands/__init__.py b/mindee/v2/commands/__init__.py index e69de29b..aa51ebe8 100644 --- a/mindee/v2/commands/__init__.py +++ b/mindee/v2/commands/__init__.py @@ -0,0 +1,22 @@ +from mindee.v2.commands.base_inference_command import BaseInferenceCommand +from mindee.v2.commands.classification_command import ClassificationCommand +from mindee.v2.commands.cli_parser import MindeeArgumentParser, MindeeParser +from mindee.v2.commands.crop_command import CropCommand +from mindee.v2.commands.extraction_command import ExtractionCommand +from mindee.v2.commands.ocr_command import OcrCommand +from mindee.v2.commands.output_type import OutputType +from mindee.v2.commands.search_models_command import SearchModelsCommand +from mindee.v2.commands.split_command import SplitCommand + +__all__ = [ + "BaseInferenceCommand", + "ClassificationCommand", + "CropCommand", + "ExtractionCommand", + "MindeeArgumentParser", + "MindeeParser", + "OcrCommand", + "OutputType", + "SearchModelsCommand", + "SplitCommand", +] diff --git a/mindee/v2/commands/base_inference_command.py b/mindee/v2/commands/base_inference_command.py new file mode 100644 index 00000000..56820213 --- /dev/null +++ b/mindee/v2/commands/base_inference_command.py @@ -0,0 +1,173 @@ +from abc import abstractmethod +from argparse import ArgumentParser, Namespace, _SubParsersAction +from collections.abc import Callable + +from mindee import ( + ClassificationResponse, + CropResponse, + ExtractionResponse, + OCRResponse, + SplitResponse, +) +from mindee.input import PathInput, URLInputSource +from mindee.v2.client import Client +from mindee.v2.client_options.base_parameters import BaseParameters +from mindee.v2.commands.output_type import OutputType + + +class BaseInferenceCommand: + """Abstract base class for V2 inference CLI commands. + + Owns the options shared by every V2 inference product + (``path``, ``--model-id``, ``--api-key``, ``--alias``, ``--output``), + the input-source resolution, the client invocation, and the output + formatting. Each concrete subclass owns its own product-specific + options, builds the right :class:`BaseParameters` instance, and may + customize the human-readable output. + + Mirrors the canonical PHP implementation in + ``mindee-api-php/bin/V2/BaseInferenceCommand.php``. + """ + + name: str + """Name of the subcommand (also used as product key).""" + + description: str + """Human-readable description shown in ``--help``.""" + + def register(self, subparsers: _SubParsersAction) -> ArgumentParser: + """Register this command on the given subparsers action.""" + parser = subparsers.add_parser( + self.name, + help=self.description, + description=self.description, + ) + parser.add_argument( + "-k", + "--api-key", + dest="api_key", + help="Mindee V2 API key.", + required=False, + default=None, + ) + parser.add_argument( + "-m", + "--model-id", + dest="model_id", + help="ID of the model to use.", + required=True, + ) + parser.add_argument( + "-a", + "--alias", + dest="alias", + help="Alias for the file.", + required=False, + default=None, + ) + parser.add_argument( + "-o", + "--output", + dest="output", + choices=[item.value for item in OutputType], + default=OutputType.SUMMARY.value, + help=( + "Specify how to output the data.\n" + "- summary: a basic summary (default)\n" + "- full: detailed extraction results, including options\n" + "- raw: full JSON object\n" + ), + ) + self.configure_product_options(parser) + parser.add_argument("path", help="The path of the file to parse.") + return parser + + def configure_product_options(self, parser: ArgumentParser) -> None: + """Hook for subclasses to add product-specific options. + + No-op by default. Override (for example in + :class:`~mindee.v2.commands.extraction_command.ExtractionCommand`) + to add flags only relevant to a single product. + """ + + def execute( + self, + parsed_args: Namespace, + client_factory: Callable[[str | None], Client], + ) -> int: + """Run the inference for ``parsed_args`` using ``client_factory``.""" + api_key = getattr(parsed_args, "api_key", None) + model_id = parsed_args.model_id + alias = getattr(parsed_args, "alias", None) + output_type = OutputType( + getattr(parsed_args, "output", OutputType.SUMMARY.value) + ) + + client = client_factory(api_key) + params = self.build_parameters(parsed_args, model_id, alias) + input_source = _build_input_source(parsed_args.path) + response: ( + ExtractionResponse + | CropResponse + | ClassificationResponse + | SplitResponse + | OCRResponse + ) = client.enqueue_and_get_result( + response_type=self.get_response_class(), + input_source=input_source, + params=params, + ) + self._print_response(parsed_args, response, output_type) + return 0 + + @abstractmethod + def build_parameters( + self, + parsed_args: Namespace, + model_id: str, + alias: str | None, + ) -> BaseParameters: + """Build the V2 inference parameters for this product.""" + + @abstractmethod + def get_response_class(self) -> type: + """Return the product response class to deserialize the API result into.""" + + def get_summary(self, response) -> str: + """Default human-readable representation of an inference response.""" + inference = getattr(response, "inference", None) + if inference is None: + return "" + return str(inference.result) + + def get_full_output(self, parsed_args: Namespace, response) -> str: + """Detailed representation of an inference response. + + Defaults to the full inference dump; override to add + product-specific sections (raw text, RAG, ...). + """ + del parsed_args + inference = getattr(response, "inference", None) + if inference is None: + return "" + return str(inference) + + def _print_response( + self, + parsed_args: Namespace, + response, + output_type: OutputType, + ) -> None: + if output_type is OutputType.RAW: + print(response.raw_http) + return + if output_type is OutputType.FULL: + print(self.get_full_output(parsed_args, response)) + return + print(self.get_summary(response)) + + +def _build_input_source(path: str) -> PathInput | URLInputSource: + if path.lower().startswith("http"): + return URLInputSource(path) + return PathInput(path) diff --git a/mindee/v2/commands/classification_command.py b/mindee/v2/commands/classification_command.py new file mode 100644 index 00000000..aea19a1f --- /dev/null +++ b/mindee/v2/commands/classification_command.py @@ -0,0 +1,24 @@ +from argparse import Namespace + +from mindee import ClassificationParameters, ClassificationResponse +from mindee.v2.client_options.base_parameters import BaseParameters +from mindee.v2.commands.base_inference_command import BaseInferenceCommand + + +class ClassificationCommand(BaseInferenceCommand): + """V2 CLI command for the classification utility.""" + + name = "classification" + description = "Classification utility." + + def get_response_class(self) -> type: + return ClassificationResponse + + def build_parameters( + self, + parsed_args: Namespace, + model_id: str, + alias: str | None, + ) -> BaseParameters: + del parsed_args + return ClassificationParameters(model_id=model_id, alias=alias) diff --git a/mindee/v2/commands/cli_parser.py b/mindee/v2/commands/cli_parser.py index ec2aa751..bae32429 100644 --- a/mindee/v2/commands/cli_parser.py +++ b/mindee/v2/commands/cli_parser.py @@ -1,136 +1,159 @@ +import sys from argparse import ArgumentParser, Namespace -from dataclasses import dataclass - -from mindee import ( - ClassificationParameters, - ClassificationResponse, - CropParameters, - CropResponse, - ExtractionParameters, - ExtractionResponse, - SplitParameters, - SplitResponse, +from collections.abc import Callable + +from mindee.v1.commands.cli_parser import ( + MindeeParser as V1MindeeParser, +) +from mindee.v1.commands.cli_parser import ( + register_v1_product_subparsers, ) -from mindee.input import PathInput, URLInputSource +from mindee.v1.error.mindee_api_error import MindeeAPIError from mindee.v2.client import Client -from mindee.v2.client_options.base_parameters import BaseParameters -from mindee.v2.parsing.inference.base_response import BaseResponse - - -@dataclass -class ProductConfig: - """Configuration for a command.""" - - response_class: type[BaseResponse] - params_class: type[BaseParameters] - - -PRODUCTS = { - "classification": ProductConfig( - response_class=ClassificationResponse, - params_class=ClassificationParameters, - ), - "crop": ProductConfig( - response_class=CropResponse, - params_class=CropParameters, - ), - "extraction": ProductConfig( - response_class=ExtractionResponse, - params_class=ExtractionParameters, - ), - "split": ProductConfig( - response_class=SplitResponse, - params_class=SplitParameters, - ), -} +from mindee.v2.commands.base_inference_command import BaseInferenceCommand +from mindee.v2.commands.classification_command import ClassificationCommand +from mindee.v2.commands.crop_command import CropCommand +from mindee.v2.commands.extraction_command import ExtractionCommand +from mindee.v2.commands.ocr_command import OcrCommand +from mindee.v2.commands.search_models_command import SearchModelsCommand +from mindee.v2.commands.split_command import SplitCommand +from mindee.v2.error.mindee_api_v2_error import MindeeAPIV2Error + +PROG_NAME = "mindee" +"""Program name displayed in usage strings and help output.""" class MindeeArgumentParser(ArgumentParser): - """Custom parser to simplify adding various options.""" - - def add_main_options(self) -> None: - """Adds main options for most parsings.""" - self.add_argument( - "-k", - "--key", - dest="api_key", - help="API key for the account", - required=False, - default=None, - ) - self.add_argument( - "-m", - "--model-id", - dest="model_id", - help="Model ID", - required=True, - default=None, - ) + """Top-level argument parser for the unified ``mindee`` CLI.""" + + +def _build_inference_commands() -> list[BaseInferenceCommand]: + """Return a fresh list of V2 inference command instances. - def add_path_arg(self) -> None: - """Adds options related to output/display of a document (parse, parse-queued).""" - self.add_argument(dest="path", help="Full path to the file") + Add a new product by appending its command class here. Each command + owns its own options, parameters and output formatting; there is no + central registry to keep in sync. + """ + return [ + ClassificationCommand(), + CropCommand(), + ExtractionCommand(), + OcrCommand(), + SplitCommand(), + ] class MindeeParser: - """Custom parser for the Mindee CLI.""" + """ + Top-level parser for the unified ``mindee`` CLI. + + The shape mirrors the .NET ``Mindee.Cli`` binary: + + * V2 inference commands are exposed at the root level + (``classification``, ``crop``, ``extraction``, ``ocr``, ``split``). + * The ``search-models`` utility is also at the root. + * V1 product commands are wrapped under a ``v1`` subcommand. + """ parser: MindeeArgumentParser - """Parser options.""" parsed_args: Namespace - """Stores attributes relating to parsing.""" - client: Client - """Mindee client""" - input_source: PathInput | URLInputSource - """Document to be parsed.""" + _client_factory: Callable[[str | None], Client] + _inference_commands: dict[str, BaseInferenceCommand] + _search_models_command: SearchModelsCommand def __init__( self, parser: MindeeArgumentParser | None = None, parsed_args: Namespace | None = None, - client: Client | None = None, + client_factory: Callable[[str | None], Client] | None = None, ) -> None: self.parser = ( - parser if parser else MindeeArgumentParser(description="Mindee_API") + parser + if parser + else MindeeArgumentParser(prog=PROG_NAME, description="Mindee CLI") ) - self.parsed_args = parsed_args if parsed_args else self._set_args() - if client: - self.client = client + self._inference_commands = { + cmd.name: cmd for cmd in _build_inference_commands() + } + self._search_models_command = SearchModelsCommand() + if parsed_args is None: + self._build_parser() + self.parsed_args = self.parser.parse_args() else: - api_key = self.parsed_args.api_key if "api_key" in self.parsed_args else "" - self.client = Client(api_key=api_key) - self.input_source = self._get_input_source() - - def call_parse(self) -> None: - """Calls the parse method of the input document.""" - product_conf = PRODUCTS[self.parsed_args.product_name] - response = self.client.enqueue_and_get_result( - response_type=product_conf.response_class, - input_source=self.input_source, - params=product_conf.params_class( - model_id=self.parsed_args.model_id, - ), + self.parsed_args = parsed_args + self._client_factory = client_factory or _default_client_factory + + def call_parse(self) -> int: + """Dispatch the parsed command to its handler. + + :returns: The exit code (``0`` on success, ``1`` on a recoverable + CLI error such as a missing API key). + """ + cmd = getattr(self.parsed_args, "cmd", None) + if cmd is None: + print("Please specify a subcommand.\n") + self.parser.print_help() + return 1 + try: + if cmd == "v1": + v1_parser = V1MindeeParser(parsed_args=self.parsed_args) + v1_parser.call_parse() + return 0 + if cmd == self._search_models_command.name: + return self._search_models_command.execute( + self.parsed_args, self._client_factory + ) + inference_command = self._inference_commands.get(cmd) + if inference_command is None: + raise ValueError(f"Unknown command: {cmd}") + return inference_command.execute(self.parsed_args, self._client_factory) + except MindeeAPIV2Error as exc: + return _report_api_key_error(exc, "V2", "MINDEE_V2_API_KEY") + except MindeeAPIError as exc: + return _report_api_key_error(exc, "V1", "MINDEE_API_KEY") + + def _build_parser(self) -> None: + # ``--verbose`` / ``-v`` are pre-consumed in ``mindee.cli.main`` + # (mirroring the .NET ``args.Contains("--verbose")`` pattern); we + # still register them here for ``--help`` discoverability. + self.parser.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="Enable diagnostic logging (repeat for debug-level output).", ) - print(response.inference) + subparsers = self.parser.add_subparsers(dest="cmd", required=False) + + for cmd in self._inference_commands.values(): + cmd.register(subparsers) - def _set_args(self) -> Namespace: - """Parse command line arguments.""" - parse_product_subparsers = self.parser.add_subparsers( - dest="product_name", - required=True, + self._search_models_command.register(subparsers) + + v1_parser = subparsers.add_parser( + "v1", + help="Mindee V1 product commands.", + description="Mindee V1 product commands.", ) - for name in PRODUCTS: - parse_subparser = parse_product_subparsers.add_parser(name) + register_v1_product_subparsers(v1_parser) + - parse_subparser.add_main_options() - parse_subparser.add_path_arg() +def _default_client_factory(api_key: str | None) -> Client: + return Client(api_key=api_key) if api_key else Client() - parsed_args = self.parser.parse_args() - return parsed_args - def _get_input_source(self) -> PathInput | URLInputSource: - """Loads an input document.""" +def _report_api_key_error(exc: Exception, version: str, env_var: str) -> int: + """Print a friendly missing-key message to stderr and return exit code 1. - if self.parsed_args.path.lower().startswith("http"): - return URLInputSource(self.parsed_args.path) - return PathInput(self.parsed_args.path) + Mirrors the .NET CLI's handling of ``OptionsValidationException`` when + the API key cannot be resolved from the command line or the environment. + """ + message = str(exc) or "API key is missing." + if "Missing API key" in message or "api key" in message.lower(): + message = ( + f"The Mindee {version} API key is missing. " + f"Please provide it via the '--api-key' option " + f"or the '{env_var}' environment variable." + ) + print(f"Error: {message}", file=sys.stderr) + return 1 diff --git a/mindee/v2/commands/crop_command.py b/mindee/v2/commands/crop_command.py new file mode 100644 index 00000000..647980c0 --- /dev/null +++ b/mindee/v2/commands/crop_command.py @@ -0,0 +1,24 @@ +from argparse import Namespace + +from mindee import CropParameters, CropResponse +from mindee.v2.client_options.base_parameters import BaseParameters +from mindee.v2.commands.base_inference_command import BaseInferenceCommand + + +class CropCommand(BaseInferenceCommand): + """V2 CLI command for the crop utility.""" + + name = "crop" + description = "Crop utility." + + def get_response_class(self) -> type: + return CropResponse + + def build_parameters( + self, + parsed_args: Namespace, + model_id: str, + alias: str | None, + ) -> BaseParameters: + del parsed_args + return CropParameters(model_id=model_id, alias=alias) diff --git a/mindee/v2/commands/extraction_command.py b/mindee/v2/commands/extraction_command.py new file mode 100644 index 00000000..40b500d6 --- /dev/null +++ b/mindee/v2/commands/extraction_command.py @@ -0,0 +1,114 @@ +from argparse import ArgumentParser, Namespace + +from mindee import ExtractionParameters, ExtractionResponse +from mindee.v2.client_options.base_parameters import BaseParameters +from mindee.v2.commands.base_inference_command import BaseInferenceCommand + + +class ExtractionCommand(BaseInferenceCommand): + """V2 CLI command for the generic all-purpose extraction utility. + + Owns the extraction-only flags (``--rag``, ``--raw-text``, + ``--confidence``, ``--polygon``, ``--text-context``) and prepends the + optional Raw Text / RAG sections to the ``full`` output. + """ + + name = "extraction" + description = "Generic all-purpose extraction." + + def configure_product_options(self, parser: ArgumentParser) -> None: + parser.add_argument( + "-g", + "--rag", + dest="rag", + action="store_true", + help=( + "Enable Retrieval-Augmented Generation. " + "Only valid for the 'extraction' product." + ), + ) + parser.add_argument( + "-r", + "--raw-text", + dest="raw_text", + action="store_true", + help="Extract the full text content from the document.", + ) + parser.add_argument( + "-c", + "--confidence", + dest="confidence", + action="store_true", + help="Retrieve confidence scores for each field.", + ) + parser.add_argument( + "-p", + "--polygon", + dest="polygon", + action="store_true", + help="Retrieve bounding-box polygons for each field.", + ) + parser.add_argument( + "-t", + "--text-context", + dest="text_context", + help="Additional text context used by the model during inference.", + default=None, + ) + + def get_response_class(self) -> type: + return ExtractionResponse + + def build_parameters( + self, + parsed_args: Namespace, + model_id: str, + alias: str | None, + ) -> BaseParameters: + return ExtractionParameters( + model_id=model_id, + alias=alias, + rag=True if getattr(parsed_args, "rag", False) else None, + raw_text=True if getattr(parsed_args, "raw_text", False) else None, + polygon=True if getattr(parsed_args, "polygon", False) else None, + confidence=(True if getattr(parsed_args, "confidence", False) else None), + text_context=getattr(parsed_args, "text_context", None), + ) + + def get_full_output(self, parsed_args: Namespace, response) -> str: + inference = getattr(response, "inference", None) + if inference is None: + return "" + + sections: list[str] = [] + active_options = getattr(inference, "active_options", None) + result = getattr(inference, "result", None) + + if ( + getattr(parsed_args, "raw_text", False) + and active_options is not None + and getattr(active_options, "raw_text", False) + and result is not None + and getattr(result, "raw_text", None) is not None + ): + raw_text_str = str(result.raw_text).replace("\n", "\n ") + sections.append("#############\nRaw Text\n#############\n::") + sections.append(" " + raw_text_str) + sections.append("") + + if ( + getattr(parsed_args, "rag", False) + and active_options is not None + and getattr(active_options, "rag", False) + and result is not None + and getattr(result, "rag", None) is not None + ): + rag_str = str(result.rag).replace("\n", "\n ") + sections.append( + "#############\nRetrieval-Augmented Generation\n#############\n::" + ) + sections.append(" " + rag_str) + sections.append("") + + sections.append(str(inference)) + return "\n".join(sections) diff --git a/mindee/v2/commands/ocr_command.py b/mindee/v2/commands/ocr_command.py new file mode 100644 index 00000000..a5063367 --- /dev/null +++ b/mindee/v2/commands/ocr_command.py @@ -0,0 +1,24 @@ +from argparse import Namespace + +from mindee import OCRParameters, OCRResponse +from mindee.v2.client_options.base_parameters import BaseParameters +from mindee.v2.commands.base_inference_command import BaseInferenceCommand + + +class OcrCommand(BaseInferenceCommand): + """V2 CLI command for the OCR utility.""" + + name = "ocr" + description = "OCR utility." + + def get_response_class(self) -> type: + return OCRResponse + + def build_parameters( + self, + parsed_args: Namespace, + model_id: str, + alias: str | None, + ) -> BaseParameters: + del parsed_args + return OCRParameters(model_id=model_id, alias=alias) diff --git a/mindee/v2/commands/output_type.py b/mindee/v2/commands/output_type.py new file mode 100644 index 00000000..314d0b1f --- /dev/null +++ b/mindee/v2/commands/output_type.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class OutputType(str, Enum): + """How to output the response from a V2 inference command.""" + + SUMMARY = "summary" + """Document-level summary in rST format (default).""" + FULL = "full" + """Complete response in rST format, including active options.""" + RAW = "raw" + """Raw JSON response.""" diff --git a/mindee/v2/commands/search_models_command.py b/mindee/v2/commands/search_models_command.py new file mode 100644 index 00000000..c8bd465e --- /dev/null +++ b/mindee/v2/commands/search_models_command.py @@ -0,0 +1,85 @@ +from argparse import ArgumentParser, Namespace, _SubParsersAction +from collections.abc import Callable + +from mindee.v2.client import Client + +_AVAILABLE_MODEL_TYPES: list[str] = [ + "extraction", + "crop", + "classification", + "ocr", + "split", +] + + +class SearchModelsCommand: + """Builder + handler for the V2 ``search-models`` subcommand. + + Mirrors ``Mindee.Cli.Commands.V2.SearchModelsCommand`` from the .NET + SDK. + """ + + name = "search-models" + description = "Search available models." + + def register(self, subparsers: _SubParsersAction) -> ArgumentParser: + """Register this command on the given subparsers action.""" + parser = subparsers.add_parser( + self.name, + help=self.description, + description=self.description, + ) + parser.add_argument( + "-k", + "--api-key", + dest="api_key", + help="Mindee V2 API key.", + required=False, + default=None, + ) + parser.add_argument( + "-n", + "--name", + dest="name", + help="Filter by model name partial match (case insensitive).", + required=False, + default=None, + ) + model_type_help = ( + "Filter by exact model type (case sensitive).\nAvailable options:\n - " + + "\n - ".join(_AVAILABLE_MODEL_TYPES) + ) + parser.add_argument( + "-m", + "--model-type", + dest="model_type", + help=model_type_help, + choices=_AVAILABLE_MODEL_TYPES, + required=False, + default=None, + ) + parser.add_argument( + "-r", + "--raw-json", + dest="raw_json", + action="store_true", + help="Whether to output the raw JSON response.", + ) + return parser + + def execute( + self, + parsed_args: Namespace, + client_factory: Callable[[str | None], Client], + ) -> int: + """Run the search and print the result.""" + client = client_factory(getattr(parsed_args, "api_key", None)) + response = client.search_models( + name=getattr(parsed_args, "name", None), + model_type=getattr(parsed_args, "model_type", None), + ) + if getattr(parsed_args, "raw_json", False): + print(response.raw_http) + else: + print(response) + return 0 diff --git a/mindee/v2/commands/split_command.py b/mindee/v2/commands/split_command.py new file mode 100644 index 00000000..6f62f940 --- /dev/null +++ b/mindee/v2/commands/split_command.py @@ -0,0 +1,24 @@ +from argparse import Namespace + +from mindee import SplitParameters, SplitResponse +from mindee.v2.client_options.base_parameters import BaseParameters +from mindee.v2.commands.base_inference_command import BaseInferenceCommand + + +class SplitCommand(BaseInferenceCommand): + """V2 CLI command for the split utility.""" + + name = "split" + description = "Split utility." + + def get_response_class(self) -> type: + return SplitResponse + + def build_parameters( + self, + parsed_args: Namespace, + model_id: str, + alias: str | None, + ) -> BaseParameters: + del parsed_args + return SplitParameters(model_id=model_id, alias=alias) diff --git a/mindee/v2/mindee_http/mindee_api_v2.py b/mindee/v2/mindee_http/mindee_api_v2.py index 0e75baae..f25e15c8 100644 --- a/mindee/v2/mindee_http/mindee_api_v2.py +++ b/mindee/v2/mindee_http/mindee_api_v2.py @@ -204,10 +204,15 @@ def req_get_search_models( get_kwargs["timeout"] = self.request_timeout else: get_caller = self.http_client.get + params = {} + if name: + params["name"] = name + if model_type: + params["model_type"] = model_type return get_caller( url=f"{self.url_root}/v2/search/models", headers=self.base_headers, - params={"name": name, "model_type": model_type}, + params=params, follow_redirects=False, **get_kwargs, ) diff --git a/mindee/v2/parsing/search/search_response.py b/mindee/v2/parsing/search/search_response.py index 5dea6b22..a2be2b66 100644 --- a/mindee/v2/parsing/search/search_response.py +++ b/mindee/v2/parsing/search/search_response.py @@ -1,9 +1,10 @@ +from mindee.parsing.common.common_response import CommonResponse from mindee.parsing.common.string_dict import StringDict from mindee.v2.parsing.search.paginationmetadata import PaginationMetadata from mindee.v2.parsing.search.search_models import SearchModels -class SearchResponse: +class SearchResponse(CommonResponse): """Models search response.""" models: SearchModels @@ -12,6 +13,7 @@ class SearchResponse: """Pagination metadata for the search results.""" def __init__(self, raw_response: StringDict) -> None: + super().__init__(raw_response) self.models = SearchModels(raw_response["models"]) self.pagination = PaginationMetadata(raw_response["pagination"]) diff --git a/pyproject.toml b/pyproject.toml index b44ed009..ee47cb48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,6 @@ build = [ [project.scripts] mindee = "mindee.cli:main" -mindeeV2 = "mindee.v2.cli:main" [tool.setuptools.packages] @@ -76,7 +75,14 @@ version = { attr = "mindee.versions.__version__" } [tool.setuptools.package-data] "mindee" = ["py.typed"] - +[tool.coverage.run] +omit = [ + "examples/*", + "scripts/*", + "mindee/cli.py", + "mindee/v1/commands/*", + "mindee/v2/commands/*", +] [tool.ruff] line-length = 88 diff --git a/tests/test_v1_cli.sh b/tests/test_v1_cli.sh new file mode 100755 index 00000000..d61fa544 --- /dev/null +++ b/tests/test_v1_cli.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -e + +TEST_FILE=$1 +PYTHON_BIN=$2 + +if [ -z "$TEST_FILE" ]; then + TEST_FILE='./tests/data/file_types/pdf/blank_1.pdf' +fi +echo "TEST_FILE: ${TEST_FILE}" + +if [ -z "$PYTHON_BIN" ]; then + PYTHON_BIN="python" +fi +echo "PYTHON_BIN: ${PYTHON_BIN}" + +PRODUCTS="financial-document receipt invoice invoice-splitter" +PRODUCTS_SIZE=4 +i=1 + +for product in $PRODUCTS +do + echo "--- Test $product with Summary Output ($i/$PRODUCTS_SIZE) ---" + SUMMARY_OUTPUT=$("$PYTHON_BIN" -m mindee v1 "$product" "$TEST_FILE") + echo "$SUMMARY_OUTPUT" + echo "" + echo "" + sleep 0.5 + i=$((i + 1)) +done diff --git a/tests/test_v2_cli.sh b/tests/test_v2_cli.sh new file mode 100755 index 00000000..9f50532f --- /dev/null +++ b/tests/test_v2_cli.sh @@ -0,0 +1,42 @@ +#!/bin/sh +set -e + +TEST_FILE=$1 +PYTHON_BIN=$2 + +if [ -z "$TEST_FILE" ]; then + TEST_FILE='./tests/data/file_types/pdf/blank_1.pdf' +fi +echo "TEST_FILE: ${TEST_FILE}" + +if [ -z "$PYTHON_BIN" ]; then + PYTHON_BIN="python" +fi +echo "PYTHON_BIN: ${PYTHON_BIN}" + +echo "--- Test model list retrieval" +MODELS=$("$PYTHON_BIN" -m mindee search-models) +if [ -z "$MODELS" ]; then + echo "Error: no models found" + exit 1 +else + echo "Models retrieval OK" +fi + +run_test() { + model_id="$1" + model_type="$2" + + echo "--- Test $model_type ID: $model_id" + SUMMARY_OUTPUT=$("$PYTHON_BIN" -m mindee "$model_type" -m "$model_id" "$TEST_FILE") + echo "$SUMMARY_OUTPUT" + echo "" + echo "" + sleep 0.5 +} + +run_test "$MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID" "extraction" +run_test "$MINDEE_V2_SE_TESTS_CROP_MODEL_ID" "crop" +run_test "$MINDEE_V2_SE_TESTS_SPLIT_MODEL_ID" "split" +run_test "$MINDEE_V2_SE_TESTS_CLASSIFICATION_MODEL_ID" "classification" +run_test "$MINDEE_V2_SE_TESTS_OCR_MODEL_ID" "ocr" diff --git a/tests/v2/test_cli.py b/tests/v2/test_cli.py new file mode 100644 index 00000000..2dc74f31 --- /dev/null +++ b/tests/v2/test_cli.py @@ -0,0 +1,261 @@ +import pytest + +from mindee.v2.commands import ( + BaseInferenceCommand, + ClassificationCommand, + CropCommand, + ExtractionCommand, + MindeeArgumentParser, + MindeeParser, + OcrCommand, + OutputType, + SplitCommand, +) +from tests.utils import V2_PRODUCT_DATA_DIR, clear_envvars + + +@pytest.fixture +def parser() -> MindeeParser: + """Build a fully wired MindeeParser without parsing args yet.""" + p = MindeeParser.__new__(MindeeParser) + p.parser = MindeeArgumentParser(description="Mindee_API") + from mindee.v2.commands.cli_parser import ( + _build_inference_commands, + _default_client_factory, + ) + from mindee.v2.commands.search_models_command import ( + SearchModelsCommand, + ) + + p._inference_commands = {cmd.name: cmd for cmd in _build_inference_commands()} + p._search_models_command = SearchModelsCommand() + p._client_factory = _default_client_factory + p._build_parser() + return p + + +def test_top_level_subcommands_registered(parser: MindeeParser): + """All V2 inference subcommands + search-models + v1 are reachable.""" + expected = { + "classification", + "crop", + "extraction", + "ocr", + "split", + "search-models", + "v1", + } + actions = [a for a in parser.parser._actions if a.dest == "cmd"] + assert actions, "cmd subparsers action missing" + assert expected.issubset(set(actions[0].choices.keys())) + + +def test_extraction_command_exposes_full_flag_set(parser: MindeeParser): + """Extraction must expose --rag, --raw-text, --confidence, --polygon, --text-context.""" + ns = parser.parser.parse_args( + [ + "extraction", + "--api-key", + "dummy", + "--model-id", + "model-1", + "--alias", + "my-alias", + "--rag", + "--raw-text", + "--confidence", + "--polygon", + "--text-context", + "ctx", + "--output", + "full", + "path/to/file.pdf", + ] + ) + assert ns.cmd == "extraction" + assert ns.api_key == "dummy" + assert ns.model_id == "model-1" + assert ns.alias == "my-alias" + assert ns.rag is True + assert ns.raw_text is True + assert ns.confidence is True + assert ns.polygon is True + assert ns.text_context == "ctx" + assert ns.output == OutputType.FULL.value + assert ns.path == "path/to/file.pdf" + + +def test_extraction_short_flags(parser: MindeeParser): + """Extraction must accept the short form of every documented flag.""" + ns = parser.parser.parse_args( + [ + "extraction", + "-k", + "dummy", + "-m", + "model-1", + "-a", + "alias", + "-g", + "-r", + "-c", + "-p", + "-t", + "ctx", + "-o", + "raw", + "path/to/file.pdf", + ] + ) + assert ns.rag is True + assert ns.raw_text is True + assert ns.confidence is True + assert ns.polygon is True + assert ns.text_context == "ctx" + assert ns.output == OutputType.RAW.value + + +@pytest.mark.parametrize( + "command", + ["classification", "crop", "ocr", "split"], +) +def test_non_extraction_commands_omit_extraction_only_flags( + parser: MindeeParser, command: str +): + """Non-extraction commands must reject --rag/--raw-text/--confidence/--polygon/--text-context.""" + with pytest.raises(SystemExit): + parser.parser.parse_args( + [command, "--model-id", "x", "--rag", "path/to/file.pdf"] + ) + + +def test_search_models_flags(parser: MindeeParser): + ns = parser.parser.parse_args( + [ + "search-models", + "--api-key", + "dummy", + "--name", + "invoice", + "--model-type", + "extraction", + "--raw-json", + ] + ) + assert ns.cmd == "search-models" + assert ns.api_key == "dummy" + assert ns.name == "invoice" + assert ns.model_type == "extraction" + assert ns.raw_json is True + + +def test_search_models_rejects_invalid_model_type(parser: MindeeParser): + with pytest.raises(SystemExit): + parser.parser.parse_args(["search-models", "--model-type", "nope"]) + + +def test_v1_group_dispatches_to_v1_product(parser: MindeeParser): + """The `v1` group preserves the existing V1 product subcommand shape.""" + ns = parser.parser.parse_args( + [ + "v1", + "invoice", + "--key", + "dummy", + "--output-type", + "summary", + str( + V2_PRODUCT_DATA_DIR + / "extraction" + / "financial_document" + / "complete.json" + ), + ] + ) + assert ns.cmd == "v1" + assert ns.product_name == "invoice" + assert ns.api_key == "dummy" + assert ns.output_type == "summary" + + +def test_extraction_dispatches_to_inference_command(monkeypatch, parser: MindeeParser): + """call_parse delegates to the InferenceCommand.execute for V2 product cmds.""" + captured = {} + + def fake_execute(args, factory): + captured["cmd"] = "extraction" + captured["model_id"] = args.model_id + return 0 + + monkeypatch.setattr( + parser._inference_commands["extraction"], "execute", fake_execute + ) + parser.parsed_args = parser.parser.parse_args( + ["extraction", "-k", "x", "-m", "m1", "some/path.pdf"] + ) + parser.call_parse() + assert captured == {"cmd": "extraction", "model_id": "m1"} + + +def test_search_models_dispatches_to_search_command(monkeypatch, parser: MindeeParser): + captured = {} + + def fake_execute(args, factory): + captured["name"] = args.name + captured["model_type"] = args.model_type + return 0 + + monkeypatch.setattr(parser._search_models_command, "execute", fake_execute) + parser.parsed_args = parser.parser.parse_args( + ["search-models", "-n", "inv", "-m", "extraction"] + ) + parser.call_parse() + assert captured == {"name": "inv", "model_type": "extraction"} + + +def test_v1_group_delegates_to_v1_mindee_parser(monkeypatch, parser: MindeeParser): + """`v1` command instantiates the V1 MindeeParser with the parsed args.""" + seen = {} + + class _FakeV1Parser: + def __init__(self, parsed_args): + seen["args"] = parsed_args + + def call_parse(self): + seen["called"] = True + + monkeypatch.setattr( + "mindee.v2.commands.cli_parser.V1MindeeParser", + _FakeV1Parser, + raising=True, + ) + clear_envvars(monkeypatch) + parser.parsed_args = parser.parser.parse_args( + ["v1", "invoice", "--key", "dummy", "path/to/file.pdf"] + ) + parser.call_parse() + assert seen["called"] is True + assert seen["args"].product_name == "invoice" + assert seen["args"].api_key == "dummy" + + +def test_each_inference_command_is_self_contained(parser: MindeeParser): + """Every V2 inference command is its own subclass of BaseInferenceCommand. + + Locks in the per-product class architecture: the dispatcher must hold + distinct ``BaseInferenceCommand`` instances rather than a single + config-driven command, so future products that don't fit the + document-extraction shape can simply not extend this base. + """ + expected = { + "classification": ClassificationCommand, + "crop": CropCommand, + "extraction": ExtractionCommand, + "ocr": OcrCommand, + "split": SplitCommand, + } + for name, cls in expected.items(): + cmd = parser._inference_commands[name] + assert isinstance(cmd, BaseInferenceCommand) + assert isinstance(cmd, cls) + assert cmd.name == name