From 96e4e6304323a224d69040afb5e6fa3d23d6a115 Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:50:07 +0200 Subject: [PATCH 1/4] :recycle: harmonize V2 CLI with other SDKs, add tests --- .github/workflows/_test-cli.yml | 72 +++++ .github/workflows/cron.yml | 3 + .github/workflows/pull-request.yml | 4 + CHANGELOG.md | 11 + mindee/cli.py | 74 ++++- mindee/logger.py | 2 +- mindee/v1/commands/__init__.py | 7 +- mindee/v1/commands/cli_parser.py | 77 +++--- mindee/v2/cli.py | 11 - mindee/v2/commands/__init__.py | 16 ++ mindee/v2/commands/cli_parser.py | 246 ++++++++++------- mindee/v2/commands/inference_command.py | 291 ++++++++++++++++++++ mindee/v2/commands/output_type.py | 12 + mindee/v2/commands/search_models_command.py | 85 ++++++ mindee/v2/parsing/search/search_response.py | 4 +- pyproject.toml | 1 - tests/test_v1_cli.sh | 30 ++ tests/test_v2_cli.sh | 42 +++ tests/v2/test_cli.py | 229 +++++++++++++++ 19 files changed, 1065 insertions(+), 152 deletions(-) create mode 100644 .github/workflows/_test-cli.yml delete mode 100644 mindee/v2/cli.py create mode 100644 mindee/v2/commands/inference_command.py create mode 100644 mindee/v2/commands/output_type.py create mode 100644 mindee/v2/commands/search_models_command.py create mode 100755 tests/test_v1_cli.sh create mode 100755 tests/test_v2_cli.sh create mode 100644 tests/v2/test_cli.py diff --git a/.github/workflows/_test-cli.yml b/.github/workflows/_test-cli.yml new file mode 100644 index 00000000..1c9c9c7e --- /dev/null +++ b/.github/workflows/_test-cli.yml @@ -0,0 +1,72 @@ +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 }} + +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..bf1e9088 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -11,3 +11,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..b6a0bab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 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 +### 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..969ad461 100644 --- a/mindee/v2/commands/__init__.py +++ b/mindee/v2/commands/__init__.py @@ -0,0 +1,16 @@ +from mindee.v2.commands.cli_parser import MindeeArgumentParser, MindeeParser +from mindee.v2.commands.inference_command import ( + InferenceCommand, + InferenceCommandOptions, +) +from mindee.v2.commands.output_type import OutputType +from mindee.v2.commands.search_models_command import SearchModelsCommand + +__all__ = [ + "InferenceCommand", + "InferenceCommandOptions", + "MindeeArgumentParser", + "MindeeParser", + "OutputType", + "SearchModelsCommand", +] diff --git a/mindee/v2/commands/cli_parser.py b/mindee/v2/commands/cli_parser.py index ec2aa751..a286e150 100644 --- a/mindee/v2/commands/cli_parser.py +++ b/mindee/v2/commands/cli_parser.py @@ -1,136 +1,180 @@ +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 +from mindee.v2.commands.inference_command import ( + InferenceCommand, + InferenceCommandOptions, +) +from mindee.v2.commands.search_models_command import SearchModelsCommand +from mindee.v2.error.mindee_api_v2_error import MindeeAPIV2Error +PROG_NAME = "mindee" +"""Program name displayed in usage strings and help output.""" -@dataclass -class ProductConfig: - """Configuration for a command.""" - response_class: type[BaseResponse] - params_class: type[BaseParameters] +class MindeeArgumentParser(ArgumentParser): + """Top-level argument parser for the unified ``mindee`` CLI.""" -PRODUCTS = { - "classification": ProductConfig( - response_class=ClassificationResponse, - params_class=ClassificationParameters, - ), - "crop": ProductConfig( - response_class=CropResponse, - params_class=CropParameters, +_INFERENCE_COMMANDS: list[InferenceCommand] = [ + InferenceCommand( + InferenceCommandOptions( + name="classification", + description="Classification utility.", + ) ), - "extraction": ProductConfig( - response_class=ExtractionResponse, - params_class=ExtractionParameters, + InferenceCommand( + InferenceCommandOptions( + name="crop", + description="Crop utility.", + ) ), - "split": ProductConfig( - response_class=SplitResponse, - params_class=SplitParameters, + InferenceCommand( + InferenceCommandOptions( + name="extraction", + description="Generic all-purpose extraction.", + rag=True, + raw_text=True, + confidence=True, + polygon=True, + text_context=True, + ) ), -} - - -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, + InferenceCommand( + InferenceCommandOptions( + name="ocr", + description="OCR utility.", ) - self.add_argument( - "-m", - "--model-id", - dest="model_id", - help="Model ID", - required=True, - default=None, + ), + InferenceCommand( + InferenceCommandOptions( + name="split", + description="Split utility.", ) - - 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") + ), +] 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, InferenceCommand] + _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.options.name: cmd for cmd in _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) + + self._search_models_command.register(subparsers) - def _set_args(self) -> Namespace: - """Parse command line arguments.""" - parse_product_subparsers = self.parser.add_subparsers( - dest="product_name", - required=True, + 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() - parsed_args = self.parser.parse_args() - return parsed_args +def _default_client_factory(api_key: str | None) -> Client: + return Client(api_key=api_key) if api_key else Client() - def _get_input_source(self) -> PathInput | URLInputSource: - """Loads an input document.""" - if self.parsed_args.path.lower().startswith("http"): - return URLInputSource(self.parsed_args.path) - return PathInput(self.parsed_args.path) +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. + + 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/inference_command.py b/mindee/v2/commands/inference_command.py new file mode 100644 index 00000000..ab604b24 --- /dev/null +++ b/mindee/v2/commands/inference_command.py @@ -0,0 +1,291 @@ +from argparse import ArgumentParser, Namespace, _SubParsersAction +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from mindee import ( + ClassificationParameters, + ClassificationResponse, + CropParameters, + CropResponse, + ExtractionParameters, + ExtractionResponse, + OCRParameters, + OCRResponse, + SplitParameters, + 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 + + +@dataclass +class InferenceCommandOptions: + """Configuration for a V2 inference subcommand.""" + + name: str + """Name of the subcommand (also used as product key).""" + description: str + """Human-readable description shown in ``--help``.""" + rag: bool = False + """Whether to expose ``--rag/-g``.""" + raw_text: bool = False + """Whether to expose ``--raw-text/-r``.""" + confidence: bool = False + """Whether to expose ``--confidence/-c``.""" + polygon: bool = False + """Whether to expose ``--polygon/-p``.""" + text_context: bool = False + """Whether to expose ``--text-context/-t``.""" + + +response_types_with_result = ( + ClassificationResponse + | CropResponse + | ExtractionResponse + | OCRResponse + | SplitResponse +) + + +@dataclass +class _ProductTypes: + """Pair of response/parameter classes for a V2 inference product.""" + + response_class: type[response_types_with_result] + params_class: type[BaseParameters] + + +PRODUCTS: dict[str, _ProductTypes] = { + "classification": _ProductTypes( + response_class=ClassificationResponse, + params_class=ClassificationParameters, + ), + "crop": _ProductTypes( + response_class=CropResponse, + params_class=CropParameters, + ), + "extraction": _ProductTypes( + response_class=ExtractionResponse, + params_class=ExtractionParameters, + ), + "ocr": _ProductTypes( + response_class=OCRResponse, + params_class=OCRParameters, + ), + "split": _ProductTypes( + response_class=SplitResponse, + params_class=SplitParameters, + ), +} + + +@dataclass +class _InferenceArgs: + """Bag of parsed CLI arguments for a V2 inference run.""" + + product: str + path: str + model_id: str + alias: str | None = None + rag: bool = False + raw_text: bool = False + confidence: bool = False + polygon: bool = False + text_context: str | None = None + output: OutputType = OutputType.SUMMARY + api_key: str | None = None + + +class InferenceCommand: + """Builder + handler for a V2 inference subcommand. + + Mirrors ``Mindee.Cli.Commands.V2.InferenceCommand`` from the .NET SDK: + each command exposes ``--api-key/-k``, ``--model-id/-m``, ``--alias/-a``, + ``--output/-o``, plus an opt-in subset of ``--rag/-g``, ``--raw-text/-r``, + ``--confidence/-c``, ``--polygon/-p``, ``--text-context/-t``. + """ + + options: InferenceCommandOptions + + def __init__(self, options: InferenceCommandOptions) -> None: + self.options = options + + def register(self, subparsers: _SubParsersAction) -> ArgumentParser: + """Register this command on the given subparsers action.""" + parser = subparsers.add_parser( + self.options.name, + help=self.options.description, + description=self.options.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, + ) + if self.options.rag: + parser.add_argument( + "-g", + "--rag", + dest="rag", + action="store_true", + help=( + "Enable Retrieval-Augmented Generation. " + "Only valid for the 'extraction' product." + ), + ) + if self.options.raw_text: + parser.add_argument( + "-r", + "--raw-text", + dest="raw_text", + action="store_true", + help="Extract the full text content from the document.", + ) + if self.options.confidence: + parser.add_argument( + "-c", + "--confidence", + dest="confidence", + action="store_true", + help="Retrieve confidence scores for each field.", + ) + if self.options.polygon: + parser.add_argument( + "-p", + "--polygon", + dest="polygon", + action="store_true", + help="Retrieve bounding-box polygons for each field.", + ) + if self.options.text_context: + parser.add_argument( + "-t", + "--text-context", + dest="text_context", + help="Additional text context used by the model during inference.", + 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" + ), + ) + parser.add_argument("path", help="The path of the file to parse.") + return parser + + def execute( + self, + parsed_args: Namespace, + client_factory: Callable[[str | None], Client], + ) -> int: + """Run the inference for ``parsed_args`` using ``client_factory``.""" + args = _InferenceArgs( + product=self.options.name, + path=parsed_args.path, + model_id=parsed_args.model_id, + alias=getattr(parsed_args, "alias", None), + rag=bool(getattr(parsed_args, "rag", False)), + raw_text=bool(getattr(parsed_args, "raw_text", False)), + confidence=bool(getattr(parsed_args, "confidence", False)), + polygon=bool(getattr(parsed_args, "polygon", False)), + text_context=getattr(parsed_args, "text_context", None), + output=OutputType(getattr(parsed_args, "output", OutputType.SUMMARY.value)), + api_key=getattr(parsed_args, "api_key", None), + ) + client = client_factory(args.api_key) + params = self._build_params(args) + input_source = _build_input_source(args.path) + response = client.enqueue_and_get_result( + response_type=PRODUCTS[args.product].response_class, + input_source=input_source, + params=params, + ) + _print_response(args, response) + return 0 + + def _build_params(self, args: _InferenceArgs) -> BaseParameters: + cls = PRODUCTS[args.product].params_class + kwargs: dict[str, Any] = {"model_id": args.model_id} + if args.alias is not None: + kwargs["alias"] = args.alias + if cls is ExtractionParameters: + if self.options.rag: + kwargs["rag"] = args.rag + if self.options.raw_text: + kwargs["raw_text"] = args.raw_text + if self.options.confidence: + kwargs["confidence"] = args.confidence + if self.options.polygon: + kwargs["polygon"] = args.polygon + if self.options.text_context and args.text_context is not None: + kwargs["text_context"] = args.text_context + return cls(**kwargs) + + +def _build_input_source(path: str) -> PathInput | URLInputSource: + if path.lower().startswith("http"): + return URLInputSource(path) + return PathInput(path) + + +def _print_response(args: _InferenceArgs, response: response_types_with_result) -> None: + if args.output is OutputType.RAW: + print(response.raw_http) + return + if args.output is OutputType.FULL: + inference = response.inference + active_options = getattr(inference, "active_options", None) + result = getattr(inference, "result", None) + if ( + args.raw_text + 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 + ): + print("#############\nRaw Text\n#############\n::\n") + raw_text_str = str(result.raw_text).replace("\n", "\n ") + print(" " + raw_text_str + "\n") + if ( + args.rag + 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 + ): + print("#############\nRetrieval-Augmented Generation\n#############\n::\n") + rag_str = str(result.rag).replace("\n", "\n ") + print(" " + rag_str + "\n") + print(inference) + return + # default: summary + print(response.inference.result) 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/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..a434ac0e 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] 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..acab1426 --- /dev/null +++ b/tests/v2/test_cli.py @@ -0,0 +1,229 @@ +import pytest + +from mindee.v2.commands import MindeeArgumentParser, MindeeParser, OutputType +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 ( + _INFERENCE_COMMANDS, + _default_client_factory, + ) + from mindee.v2.commands.search_models_command import ( + SearchModelsCommand, + ) + + p._inference_commands = {cmd.options.name: cmd for cmd in _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" From 6fd33273986b922bca1f4ed317ac9a46b57d396b Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:05:07 +0200 Subject: [PATCH 2/4] :bug: fix search models sometimes breaking --- .github/workflows/_test-cli.yml | 2 ++ .github/workflows/cron.yml | 5 +++++ mindee/v2/mindee_http/mindee_api_v2.py | 7 ++++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/_test-cli.yml b/.github/workflows/_test-cli.yml index 1c9c9c7e..c9d501e4 100644 --- a/.github/workflows/_test-cli.yml +++ b/.github/workflows/_test-cli.yml @@ -12,6 +12,8 @@ env: 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: diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index bf1e9088..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 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, ) From 878d40b32f8be6f3100099cf64a30588d8eca983 Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:49:42 +0200 Subject: [PATCH 3/4] refactor CLI again --- CHANGELOG.md | 1 + mindee/v2/commands/__init__.py | 18 +- mindee/v2/commands/base_inference_command.py | 173 +++++++++++ mindee/v2/commands/classification_command.py | 24 ++ mindee/v2/commands/cli_parser.py | 65 ++--- mindee/v2/commands/crop_command.py | 24 ++ mindee/v2/commands/extraction_command.py | 114 ++++++++ mindee/v2/commands/inference_command.py | 291 ------------------- mindee/v2/commands/ocr_command.py | 24 ++ mindee/v2/commands/split_command.py | 24 ++ tests/v2/test_cli.py | 38 ++- 11 files changed, 453 insertions(+), 343 deletions(-) create mode 100644 mindee/v2/commands/base_inference_command.py create mode 100644 mindee/v2/commands/classification_command.py create mode 100644 mindee/v2/commands/crop_command.py create mode 100644 mindee/v2/commands/extraction_command.py delete mode 100644 mindee/v2/commands/inference_command.py create mode 100644 mindee/v2/commands/ocr_command.py create mode 100644 mindee/v2/commands/split_command.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a0bab2..1e9b7eaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### ¡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 diff --git a/mindee/v2/commands/__init__.py b/mindee/v2/commands/__init__.py index 969ad461..aa51ebe8 100644 --- a/mindee/v2/commands/__init__.py +++ b/mindee/v2/commands/__init__.py @@ -1,16 +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.inference_command import ( - InferenceCommand, - InferenceCommandOptions, -) +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__ = [ - "InferenceCommand", - "InferenceCommandOptions", + "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 a286e150..bae32429 100644 --- a/mindee/v2/commands/cli_parser.py +++ b/mindee/v2/commands/cli_parser.py @@ -10,11 +10,13 @@ ) from mindee.v1.error.mindee_api_error import MindeeAPIError from mindee.v2.client import Client -from mindee.v2.commands.inference_command import ( - InferenceCommand, - InferenceCommandOptions, -) +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" @@ -25,43 +27,20 @@ class MindeeArgumentParser(ArgumentParser): """Top-level argument parser for the unified ``mindee`` CLI.""" -_INFERENCE_COMMANDS: list[InferenceCommand] = [ - InferenceCommand( - InferenceCommandOptions( - name="classification", - description="Classification utility.", - ) - ), - InferenceCommand( - InferenceCommandOptions( - name="crop", - description="Crop utility.", - ) - ), - InferenceCommand( - InferenceCommandOptions( - name="extraction", - description="Generic all-purpose extraction.", - rag=True, - raw_text=True, - confidence=True, - polygon=True, - text_context=True, - ) - ), - InferenceCommand( - InferenceCommandOptions( - name="ocr", - description="OCR utility.", - ) - ), - InferenceCommand( - InferenceCommandOptions( - name="split", - description="Split utility.", - ) - ), -] +def _build_inference_commands() -> list[BaseInferenceCommand]: + """Return a fresh list of V2 inference command instances. + + 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: @@ -79,7 +58,7 @@ class MindeeParser: parser: MindeeArgumentParser parsed_args: Namespace _client_factory: Callable[[str | None], Client] - _inference_commands: dict[str, InferenceCommand] + _inference_commands: dict[str, BaseInferenceCommand] _search_models_command: SearchModelsCommand def __init__( @@ -94,7 +73,7 @@ def __init__( else MindeeArgumentParser(prog=PROG_NAME, description="Mindee CLI") ) self._inference_commands = { - cmd.options.name: cmd for cmd in _INFERENCE_COMMANDS + cmd.name: cmd for cmd in _build_inference_commands() } self._search_models_command = SearchModelsCommand() if parsed_args is None: 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/inference_command.py b/mindee/v2/commands/inference_command.py deleted file mode 100644 index ab604b24..00000000 --- a/mindee/v2/commands/inference_command.py +++ /dev/null @@ -1,291 +0,0 @@ -from argparse import ArgumentParser, Namespace, _SubParsersAction -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from mindee import ( - ClassificationParameters, - ClassificationResponse, - CropParameters, - CropResponse, - ExtractionParameters, - ExtractionResponse, - OCRParameters, - OCRResponse, - SplitParameters, - 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 - - -@dataclass -class InferenceCommandOptions: - """Configuration for a V2 inference subcommand.""" - - name: str - """Name of the subcommand (also used as product key).""" - description: str - """Human-readable description shown in ``--help``.""" - rag: bool = False - """Whether to expose ``--rag/-g``.""" - raw_text: bool = False - """Whether to expose ``--raw-text/-r``.""" - confidence: bool = False - """Whether to expose ``--confidence/-c``.""" - polygon: bool = False - """Whether to expose ``--polygon/-p``.""" - text_context: bool = False - """Whether to expose ``--text-context/-t``.""" - - -response_types_with_result = ( - ClassificationResponse - | CropResponse - | ExtractionResponse - | OCRResponse - | SplitResponse -) - - -@dataclass -class _ProductTypes: - """Pair of response/parameter classes for a V2 inference product.""" - - response_class: type[response_types_with_result] - params_class: type[BaseParameters] - - -PRODUCTS: dict[str, _ProductTypes] = { - "classification": _ProductTypes( - response_class=ClassificationResponse, - params_class=ClassificationParameters, - ), - "crop": _ProductTypes( - response_class=CropResponse, - params_class=CropParameters, - ), - "extraction": _ProductTypes( - response_class=ExtractionResponse, - params_class=ExtractionParameters, - ), - "ocr": _ProductTypes( - response_class=OCRResponse, - params_class=OCRParameters, - ), - "split": _ProductTypes( - response_class=SplitResponse, - params_class=SplitParameters, - ), -} - - -@dataclass -class _InferenceArgs: - """Bag of parsed CLI arguments for a V2 inference run.""" - - product: str - path: str - model_id: str - alias: str | None = None - rag: bool = False - raw_text: bool = False - confidence: bool = False - polygon: bool = False - text_context: str | None = None - output: OutputType = OutputType.SUMMARY - api_key: str | None = None - - -class InferenceCommand: - """Builder + handler for a V2 inference subcommand. - - Mirrors ``Mindee.Cli.Commands.V2.InferenceCommand`` from the .NET SDK: - each command exposes ``--api-key/-k``, ``--model-id/-m``, ``--alias/-a``, - ``--output/-o``, plus an opt-in subset of ``--rag/-g``, ``--raw-text/-r``, - ``--confidence/-c``, ``--polygon/-p``, ``--text-context/-t``. - """ - - options: InferenceCommandOptions - - def __init__(self, options: InferenceCommandOptions) -> None: - self.options = options - - def register(self, subparsers: _SubParsersAction) -> ArgumentParser: - """Register this command on the given subparsers action.""" - parser = subparsers.add_parser( - self.options.name, - help=self.options.description, - description=self.options.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, - ) - if self.options.rag: - parser.add_argument( - "-g", - "--rag", - dest="rag", - action="store_true", - help=( - "Enable Retrieval-Augmented Generation. " - "Only valid for the 'extraction' product." - ), - ) - if self.options.raw_text: - parser.add_argument( - "-r", - "--raw-text", - dest="raw_text", - action="store_true", - help="Extract the full text content from the document.", - ) - if self.options.confidence: - parser.add_argument( - "-c", - "--confidence", - dest="confidence", - action="store_true", - help="Retrieve confidence scores for each field.", - ) - if self.options.polygon: - parser.add_argument( - "-p", - "--polygon", - dest="polygon", - action="store_true", - help="Retrieve bounding-box polygons for each field.", - ) - if self.options.text_context: - parser.add_argument( - "-t", - "--text-context", - dest="text_context", - help="Additional text context used by the model during inference.", - 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" - ), - ) - parser.add_argument("path", help="The path of the file to parse.") - return parser - - def execute( - self, - parsed_args: Namespace, - client_factory: Callable[[str | None], Client], - ) -> int: - """Run the inference for ``parsed_args`` using ``client_factory``.""" - args = _InferenceArgs( - product=self.options.name, - path=parsed_args.path, - model_id=parsed_args.model_id, - alias=getattr(parsed_args, "alias", None), - rag=bool(getattr(parsed_args, "rag", False)), - raw_text=bool(getattr(parsed_args, "raw_text", False)), - confidence=bool(getattr(parsed_args, "confidence", False)), - polygon=bool(getattr(parsed_args, "polygon", False)), - text_context=getattr(parsed_args, "text_context", None), - output=OutputType(getattr(parsed_args, "output", OutputType.SUMMARY.value)), - api_key=getattr(parsed_args, "api_key", None), - ) - client = client_factory(args.api_key) - params = self._build_params(args) - input_source = _build_input_source(args.path) - response = client.enqueue_and_get_result( - response_type=PRODUCTS[args.product].response_class, - input_source=input_source, - params=params, - ) - _print_response(args, response) - return 0 - - def _build_params(self, args: _InferenceArgs) -> BaseParameters: - cls = PRODUCTS[args.product].params_class - kwargs: dict[str, Any] = {"model_id": args.model_id} - if args.alias is not None: - kwargs["alias"] = args.alias - if cls is ExtractionParameters: - if self.options.rag: - kwargs["rag"] = args.rag - if self.options.raw_text: - kwargs["raw_text"] = args.raw_text - if self.options.confidence: - kwargs["confidence"] = args.confidence - if self.options.polygon: - kwargs["polygon"] = args.polygon - if self.options.text_context and args.text_context is not None: - kwargs["text_context"] = args.text_context - return cls(**kwargs) - - -def _build_input_source(path: str) -> PathInput | URLInputSource: - if path.lower().startswith("http"): - return URLInputSource(path) - return PathInput(path) - - -def _print_response(args: _InferenceArgs, response: response_types_with_result) -> None: - if args.output is OutputType.RAW: - print(response.raw_http) - return - if args.output is OutputType.FULL: - inference = response.inference - active_options = getattr(inference, "active_options", None) - result = getattr(inference, "result", None) - if ( - args.raw_text - 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 - ): - print("#############\nRaw Text\n#############\n::\n") - raw_text_str = str(result.raw_text).replace("\n", "\n ") - print(" " + raw_text_str + "\n") - if ( - args.rag - 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 - ): - print("#############\nRetrieval-Augmented Generation\n#############\n::\n") - rag_str = str(result.rag).replace("\n", "\n ") - print(" " + rag_str + "\n") - print(inference) - return - # default: summary - print(response.inference.result) 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/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/tests/v2/test_cli.py b/tests/v2/test_cli.py index acab1426..2dc74f31 100644 --- a/tests/v2/test_cli.py +++ b/tests/v2/test_cli.py @@ -1,6 +1,16 @@ import pytest -from mindee.v2.commands import MindeeArgumentParser, MindeeParser, OutputType +from mindee.v2.commands import ( + BaseInferenceCommand, + ClassificationCommand, + CropCommand, + ExtractionCommand, + MindeeArgumentParser, + MindeeParser, + OcrCommand, + OutputType, + SplitCommand, +) from tests.utils import V2_PRODUCT_DATA_DIR, clear_envvars @@ -10,14 +20,14 @@ def parser() -> MindeeParser: p = MindeeParser.__new__(MindeeParser) p.parser = MindeeArgumentParser(description="Mindee_API") from mindee.v2.commands.cli_parser import ( - _INFERENCE_COMMANDS, + _build_inference_commands, _default_client_factory, ) from mindee.v2.commands.search_models_command import ( SearchModelsCommand, ) - p._inference_commands = {cmd.options.name: cmd for cmd in _INFERENCE_COMMANDS} + 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() @@ -227,3 +237,25 @@ def call_parse(self): 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 From 648b18bae3b59f06096e4ae4862cf2063f66e963 Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:07:17 +0200 Subject: [PATCH 4/4] fix coverage --- pyproject.toml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a434ac0e..ee47cb48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,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