From e9022dc92dadfd74763d8b2a633d825da8eebf51 Mon Sep 17 00:00:00 2001 From: Mehdi Ben Hamida Date: Tue, 15 Sep 2026 14:57:38 +0200 Subject: [PATCH 1/7] :test_tube: add webhook test to Python integration tests --- tests/conftest.py | 6 +++ tests/v2/test_client_integration.py | 81 +++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index b5cc6b4a..c44074e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,3 +14,9 @@ def force_gc(): def findoc_model_id() -> str: """Identifier of the Financial Document model, supplied through an env var.""" return os.getenv("MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID", "") + + +@pytest.fixture(scope="session") +def split_model_id() -> str: + """Identifier of the Split model, supplied through an env var.""" + return os.getenv("MINDEE_V2_SE_TESTS_SPLIT_MODEL_ID", "") diff --git a/tests/v2/test_client_integration.py b/tests/v2/test_client_integration.py index 35d96e37..decdc981 100644 --- a/tests/v2/test_client_integration.py +++ b/tests/v2/test_client_integration.py @@ -1,5 +1,7 @@ import os +from datetime import datetime from pathlib import Path +from time import sleep import httpx import pytest @@ -12,7 +14,10 @@ MindeeHTTPErrorV2, ) from mindee.v2.parsing import InferenceActiveOptions +from mindee.v2.parsing.job.job import Job from mindee.v2.product.extraction.extraction_response import ExtractionResponse +from mindee.v2.product.split.params.split_parameters import SplitParameters +from mindee.v2.product.split.split_response import SplitResponse from tests.utils import FILE_TYPES_PATH, V2_PRODUCT_PATH @@ -166,6 +171,82 @@ def test_parse_file_filled_single_page_must_succeed( assert len(supplier_name.locations) == 0 +def _job_is_final(job: Job) -> bool: + if job.status == "Failed": + return True + if job.status != "Processed": + return False + # Webhook deliveries happen asynchronously after processing: + # wait until every webhook has reached a final status as well. + return all(webhook.status in {"Completed", "Failed"} for webhook in job.webhooks) + + +def _enqueue_and_poll_job(v2_client: Client, input_source, params) -> Job: + """Enqueue a document and poll until the job and its webhooks reach a final status.""" + job = v2_client.enqueue(input_source, params).job + for _ in range(60): + sleep(2) + job = v2_client.get_job(job.id).job + if _job_is_final(job): + break + return job + + +def _assert_webhook_job_success(job: Job, webhook_ids: list) -> None: + assert job.status == "Processed" + assert isinstance(job.completed_at, datetime) + assert job.error is None + assert len(job.webhooks) == len(webhook_ids) + assert all(webhook.status in {"Completed", "Failed"} for webhook in job.webhooks) + assert {webhook.id for webhook in job.webhooks} == set(webhook_ids) + + +@pytest.mark.integration +@pytest.mark.v2 +def test_extraction_with_two_webhooks_must_complete_and_succeed( + v2_client: Client, findoc_model_id: str +) -> None: + webhook_ids = [ + "9a0d88be-6913-484d-a019-9d2e16e2d3b9", + "32286ed9-fe40-4f42-bdc5-2f8496c5641a", + ] + + input_source = PathInput( + V2_PRODUCT_PATH / "extraction" / "financial_document" / "default_sample.jpg" + ) + params = ExtractionParameters(model_id=findoc_model_id, webhook_ids=webhook_ids) + + job = _enqueue_and_poll_job(v2_client, input_source, params) + _assert_webhook_job_success(job, webhook_ids) + + response = v2_client.get_result_from_url(ExtractionResponse, job.result_url) + assert response.inference is not None + assert response.inference.result is not None + assert response.inference.result.fields["supplier_name"].value == "John Smith" + + +@pytest.mark.integration +@pytest.mark.v2 +def test_split_with_two_webhooks_must_complete_and_succeed( + v2_client: Client, split_model_id: str +) -> None: + webhook_ids = [ + "b8fdfea3-24b6-438a-a6ca-7cd8c87a8875", + "d5bf36a9-1301-42c7-95be-03dc20d8f10e", + ] + + input_source = PathInput(V2_PRODUCT_PATH / "split" / "default_sample.pdf") + params = SplitParameters(model_id=split_model_id, webhook_ids=webhook_ids) + + job = _enqueue_and_poll_job(v2_client, input_source, params) + _assert_webhook_job_success(job, webhook_ids) + + response = v2_client.get_result_from_url(SplitResponse, job.result_url) + assert response.inference is not None + assert response.inference.result is not None + assert len(response.inference.result.splits) == 2 + + @pytest.mark.integration @pytest.mark.v2 def test_invalid_uuid_must_throw_error(v2_client: Client) -> None: From 7d50dfd0c5133b41950d45e68231c520bc8e75df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ianar=C3=A9=20S=C3=A9vi?= Date: Thu, 1 Oct 2026 18:43:00 +0200 Subject: [PATCH 2/7] :recycle: rework result polling --- mindee/v2/client.py | 49 ++++++++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/mindee/v2/client.py b/mindee/v2/client.py index d25351c1..f9589b48 100644 --- a/mindee/v2/client.py +++ b/mindee/v2/client.py @@ -128,30 +128,40 @@ def enqueue_and_get_result( :return: A valid inference response. """ - if not params.polling_options: - params.polling_options = PollingOptions() - params.polling_options.validate_settings() enqueue_response = self.enqueue(input_source, params) logger.debug( "Successfully enqueued document with job ID: %s", enqueue_response.job.id ) + if params.polling_options: + polling_options = params.polling_options + polling_options.validate_settings() + else: + polling_options = PollingOptions() + + return self._poll_for_result( + enqueue_response, response_type, polling_options, cancellation_token + ) + + def _poll_for_result( + self, + enqueue_response: JobResponse, + response_type: type[TypeBaseInferenceResponse], + polling_options: PollingOptions, + cancellation_token: CancellationToken | None = None, + ) -> TypeBaseInferenceResponse: + """ + Poll until the inference is finished processing or the max number of attempts is reached. + """ if cancellation_token and cancellation_token.is_canceled: raise MindeeError("Request canceled through cancellation token.") - sleep(params.polling_options.initial_delay_sec) + sleep(polling_options.initial_delay_sec) try_counter = 0 - while try_counter < params.polling_options.max_retries: + while try_counter < polling_options.max_retries: if cancellation_token and cancellation_token.is_canceled: raise MindeeError("Request canceled through cancellation token.") job_response = self.get_job(enqueue_response.job.id) assert isinstance(job_response, JobResponse) - if job_response.job.status == CommonStatus.FAILED.value: - if job_response.job.error: - detail = job_response.job.error.detail - else: - detail = "No error detail available." - raise MindeeError( - f"Parsing failed for job {job_response.job.id}: {detail}" - ) + if ( job_response.job.status == CommonStatus.PROCESSED.value and job_response.job.result_url @@ -168,8 +178,19 @@ def enqueue_and_get_result( f'Invalid response type "{type(result)}"' ) return result + + # normally the mindee_api will throw on error, this is a fallback + if job_response.job.status == CommonStatus.FAILED.value: + if job_response.job.error: + detail = job_response.job.error.detail + else: + detail = "No error detail available." + raise MindeeError( + f"Parsing failed for job {job_response.job.id}: {detail}" + ) + try_counter += 1 - sleep(params.polling_options.delay_sec) + sleep(polling_options.delay_sec) raise MindeeError(f"Couldn't retrieve document after {try_counter + 1} tries.") From 76ff2fe053d6a9f443d4559f429d8e3333cb8408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ianar=C3=A9=20S=C3=A9vi?= Date: Thu, 1 Oct 2026 19:01:14 +0200 Subject: [PATCH 3/7] :white_check_mark: run some tests in parallel --- .github/workflows/_test-integrations.yml | 6 +-- .github/workflows/_test-regressions.yml | 2 +- pyproject.toml | 1 + tests/conftest.py | 22 ---------- .../test_barcode_reader_v1_regression.py | 26 ----------- ...test_bank_account_details_v1_regression.py | 28 ------------ .../test_carte_grise_v1_regression.py | 26 ----------- .../fr/id_card/test_id_card_v1_regression.py | 26 ----------- .../test_bank_check_v1_regression.py | 26 ----------- tests/v2/conftest.py | 43 +++++++++++++++++++ .../test_crop_operation_integration.py | 16 +++---- .../test_split_operation_integration.py | 17 +++----- .../test_classification_integration.py | 7 ++- .../v2/product/crop/test_crop_integration.py | 8 ---- .../test_rag_documents_integration.py | 16 +++---- tests/v2/product/ocr/test_ocr_integration.py | 10 ++--- .../product/split/test_split_integration.py | 13 ------ .../search/test_model_search_integration.py | 5 --- .../test_rag_document_search_integration.py | 14 ------ tests/v2/test_client_integration.py | 7 +-- 20 files changed, 80 insertions(+), 239 deletions(-) delete mode 100644 tests/conftest.py delete mode 100644 tests/v1/product/barcode_reader/test_barcode_reader_v1_regression.py delete mode 100644 tests/v1/product/fr/bank_account_details/test_bank_account_details_v1_regression.py delete mode 100644 tests/v1/product/fr/carte_grise/test_carte_grise_v1_regression.py delete mode 100644 tests/v1/product/fr/id_card/test_id_card_v1_regression.py delete mode 100644 tests/v1/product/us/bank_check/test_bank_check_v1_regression.py create mode 100644 tests/v2/conftest.py diff --git a/.github/workflows/_test-integrations.yml b/.github/workflows/_test-integrations.yml index 75985619..7f99636f 100644 --- a/.github/workflows/_test-integrations.yml +++ b/.github/workflows/_test-integrations.yml @@ -58,7 +58,7 @@ jobs: pip install -e '.[test]' - name: Run Integration Testing run: | - pytest --cov mindee -m integration + pytest --cov mindee -n 2 -m integration - name: Notify Slack Action on Failure uses: ravsamhq/notify-slack-action@2.5.0 @@ -71,7 +71,7 @@ jobs: SLACK_WEBHOOK_URL: ${{ secrets.PRODUCTION_ISSUES_SLACK_HOOK_URL }} pytest-lite: - name: Run Integration Tests + name: Run Integration Tests (Lite) timeout-minutes: 30 strategy: matrix: @@ -107,4 +107,4 @@ jobs: shell: bash - name: Run Integration Testing run: | - pytest -m "integration and not pypdfium2 and not pillow" + pytest -n 2 -m "integration and not pypdfium2 and not pillow" diff --git a/.github/workflows/_test-regressions.yml b/.github/workflows/_test-regressions.yml index 8829fcac..0a470b55 100644 --- a/.github/workflows/_test-regressions.yml +++ b/.github/workflows/_test-regressions.yml @@ -46,7 +46,7 @@ jobs: env: MINDEE_API_KEY: ${{ secrets.MINDEE_API_KEY_SE_TESTS }} run: | - pytest --cov mindee -m regression + pytest --cov mindee -n auto -m regression - name: Notify Slack Action on Failure uses: ravsamhq/notify-slack-action@2.5.0 diff --git a/pyproject.toml b/pyproject.toml index e0be1099..2c4159ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ lint = [ test = [ "tomli~=2.4.1", "pytest>=9.0.3,<9.2.0", + "pytest-xdist~=3.8.0", "pytest-cov~=7.1.0", "respx~=0.23.1", ] diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index c44074e4..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,22 +0,0 @@ -import gc -import os - -import pytest - - -@pytest.fixture(autouse=True) -def force_gc(): - yield - gc.collect() - - -@pytest.fixture(scope="session") -def findoc_model_id() -> str: - """Identifier of the Financial Document model, supplied through an env var.""" - return os.getenv("MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID", "") - - -@pytest.fixture(scope="session") -def split_model_id() -> str: - """Identifier of the Split model, supplied through an env var.""" - return os.getenv("MINDEE_V2_SE_TESTS_SPLIT_MODEL_ID", "") diff --git a/tests/v1/product/barcode_reader/test_barcode_reader_v1_regression.py b/tests/v1/product/barcode_reader/test_barcode_reader_v1_regression.py deleted file mode 100644 index 5f3df286..00000000 --- a/tests/v1/product/barcode_reader/test_barcode_reader_v1_regression.py +++ /dev/null @@ -1,26 +0,0 @@ -import pytest - -from mindee import PathInput -from mindee.v1.client import Client -from mindee.v1.product.barcode_reader import BarcodeReaderV1 -from tests.utils import V1_PRODUCT_PATH -from tests.v1.product import get_id, get_version - - -@pytest.mark.regression -def test_default_sample(): - client = Client() - with open( - V1_PRODUCT_PATH / "barcode_reader" / "response_v1" / "default_sample.rst", - encoding="utf-8", - ) as rst_file: - rst_ref = rst_file.read() - - sample = PathInput( - V1_PRODUCT_PATH / "barcode_reader" / "default_sample.jpg", - ) - response = client.parse(BarcodeReaderV1, sample) - doc_response = response.document - doc_response.id = get_id(rst_ref) - doc_response.inference.product.version = get_version(rst_ref) - assert str(doc_response) == rst_ref diff --git a/tests/v1/product/fr/bank_account_details/test_bank_account_details_v1_regression.py b/tests/v1/product/fr/bank_account_details/test_bank_account_details_v1_regression.py deleted file mode 100644 index 61c7d6cd..00000000 --- a/tests/v1/product/fr/bank_account_details/test_bank_account_details_v1_regression.py +++ /dev/null @@ -1,28 +0,0 @@ -import pytest - -from mindee import PathInput -from mindee.v1.client import Client -from mindee.v1.product.fr.bank_account_details.bank_account_details_v1 import ( - BankAccountDetailsV1, -) -from tests.utils import V1_PRODUCT_PATH -from tests.v1.product import get_id, get_version - - -@pytest.mark.regression -def test_default_sample(): - client = Client() - with open( - V1_PRODUCT_PATH / "bank_account_details" / "response_v1" / "default_sample.rst", - encoding="utf-8", - ) as rst_file: - rst_ref = rst_file.read() - - sample = PathInput( - V1_PRODUCT_PATH / "bank_account_details" / "default_sample.jpg", - ) - response = client.parse(BankAccountDetailsV1, sample) - doc_response = response.document - doc_response.id = get_id(rst_ref) - doc_response.inference.product.version = get_version(rst_ref) - assert str(doc_response) == rst_ref diff --git a/tests/v1/product/fr/carte_grise/test_carte_grise_v1_regression.py b/tests/v1/product/fr/carte_grise/test_carte_grise_v1_regression.py deleted file mode 100644 index c062ded7..00000000 --- a/tests/v1/product/fr/carte_grise/test_carte_grise_v1_regression.py +++ /dev/null @@ -1,26 +0,0 @@ -import pytest - -from mindee import PathInput -from mindee.v1.client import Client -from mindee.v1.product.fr.carte_grise.carte_grise_v1 import CarteGriseV1 -from tests.utils import V1_PRODUCT_PATH -from tests.v1.product import get_id, get_version - - -@pytest.mark.regression -def test_default_sample(): - client = Client() - with open( - V1_PRODUCT_PATH / "carte_grise" / "response_v1" / "default_sample.rst", - encoding="utf-8", - ) as rst_file: - rst_ref = rst_file.read() - - sample = PathInput( - V1_PRODUCT_PATH / "carte_grise" / "default_sample.jpg", - ) - response = client.parse(CarteGriseV1, sample) - doc_response = response.document - doc_response.id = get_id(rst_ref) - doc_response.inference.product.version = get_version(rst_ref) - assert str(doc_response) == rst_ref diff --git a/tests/v1/product/fr/id_card/test_id_card_v1_regression.py b/tests/v1/product/fr/id_card/test_id_card_v1_regression.py deleted file mode 100644 index b7bfb9f5..00000000 --- a/tests/v1/product/fr/id_card/test_id_card_v1_regression.py +++ /dev/null @@ -1,26 +0,0 @@ -import pytest - -from mindee import PathInput -from mindee.v1.client import Client -from mindee.v1.product.fr.id_card.id_card_v1 import IdCardV1 -from tests.utils import V1_PRODUCT_PATH -from tests.v1.product import get_id, get_version - - -@pytest.mark.regression -def test_default_sample(): - client = Client() - with open( - V1_PRODUCT_PATH / "idcard_fr" / "response_v1" / "default_sample.rst", - encoding="utf-8", - ) as rst_file: - rst_ref = rst_file.read() - - sample = PathInput( - V1_PRODUCT_PATH / "idcard_fr" / "default_sample.jpg", - ) - response = client.parse(IdCardV1, sample) - doc_response = response.document - doc_response.id = get_id(rst_ref) - doc_response.inference.product.version = get_version(rst_ref) - assert str(doc_response) == rst_ref diff --git a/tests/v1/product/us/bank_check/test_bank_check_v1_regression.py b/tests/v1/product/us/bank_check/test_bank_check_v1_regression.py deleted file mode 100644 index 3fc19253..00000000 --- a/tests/v1/product/us/bank_check/test_bank_check_v1_regression.py +++ /dev/null @@ -1,26 +0,0 @@ -import pytest - -from mindee import PathInput -from mindee.v1.client import Client -from mindee.v1.product.us.bank_check.bank_check_v1 import BankCheckV1 -from tests.utils import V1_PRODUCT_PATH -from tests.v1.product import get_id, get_version - - -@pytest.mark.regression -def test_default_sample(): - client = Client() - with open( - V1_PRODUCT_PATH / "bank_check" / "response_v1" / "default_sample.rst", - encoding="utf-8", - ) as rst_file: - rst_ref = rst_file.read() - - sample = PathInput( - V1_PRODUCT_PATH / "bank_check" / "default_sample.jpg", - ) - response = client.parse(BankCheckV1, sample) - doc_response = response.document - doc_response.id = get_id(rst_ref) - doc_response.inference.product.version = get_version(rst_ref) - assert str(doc_response) == rst_ref diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py new file mode 100644 index 00000000..70e3cdf5 --- /dev/null +++ b/tests/v2/conftest.py @@ -0,0 +1,43 @@ +import os + +import pytest + +from mindee.v2 import Client + + +@pytest.fixture(scope="session") +def v2_client() -> Client: + return Client() + + +@pytest.fixture(scope="session") +def findoc_model_id() -> str: + """Identifier of the Financial Document model, supplied through an env var.""" + findoc_model_id = os.getenv("MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID", "") + if findoc_model_id is None: + raise ValueError( + "MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID environment variable is not set" + ) + return findoc_model_id + + +@pytest.fixture(scope="session") +def split_model_id() -> str: + """Identifier of the Split model, supplied through an env var.""" + split_model_id = os.getenv("MINDEE_V2_SE_TESTS_SPLIT_MODEL_ID", "") + if split_model_id is None: + raise ValueError( + "MINDEE_V2_SE_TESTS_SPLIT_MODEL_ID environment variable is not set" + ) + return split_model_id + + +@pytest.fixture(scope="session") +def crop_model_id() -> str: + """Identifier of the Financial Document model, supplied through an env var.""" + crop_model_id = os.getenv("MINDEE_V2_SE_TESTS_CROP_MODEL_ID") + if crop_model_id is None: + raise ValueError( + "MINDEE_V2_SE_TESTS_CROP_MODEL_ID environment variable is not set" + ) + return crop_model_id diff --git a/tests/v2/file_operations/test_crop_operation_integration.py b/tests/v2/file_operations/test_crop_operation_integration.py index 49b9b261..200ddd91 100644 --- a/tests/v2/file_operations/test_crop_operation_integration.py +++ b/tests/v2/file_operations/test_crop_operation_integration.py @@ -1,5 +1,4 @@ import os -from os import getenv import pytest @@ -29,13 +28,14 @@ def check_findoc_return(findoc_response: ExtractionResponse): @pytest.mark.pillow @pytest.mark.pypdfium2 @pytest.mark.integration -def test_image_should_extract_crops(): - client = Client() +def test_image_should_extract_crops( + v2_client: Client, crop_model_id: str, findoc_model_id: str +): crop_input = PathInput(V2_PRODUCT_PATH / "crop" / "default_sample.jpg") - response = client.enqueue_and_get_result( + response = v2_client.enqueue_and_get_result( CropResponse, crop_input, - CropParameters(getenv("MINDEE_V2_SE_TESTS_CROP_MODEL_ID"), close_file=False), + CropParameters(crop_model_id, close_file=False), ) assert len(response.inference.result.crops) == 2 @@ -47,12 +47,10 @@ def test_image_should_extract_crops(): assert extracted_crops[0].filename == output_files[0] assert extracted_crops[1].filename == output_files[1] - invoice_0 = client.enqueue_and_get_result( + invoice_0 = v2_client.enqueue_and_get_result( ExtractionResponse, extracted_crops[0].as_input_source(), - ExtractionParameters( - getenv("MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID"), close_file=False - ), + ExtractionParameters(findoc_model_id, close_file=False), ) check_findoc_return(invoice_0) extracted_crops.save_all_to_disk(OUTPUT_PATH) diff --git a/tests/v2/file_operations/test_split_operation_integration.py b/tests/v2/file_operations/test_split_operation_integration.py index 803d102e..0bbd0bc5 100644 --- a/tests/v2/file_operations/test_split_operation_integration.py +++ b/tests/v2/file_operations/test_split_operation_integration.py @@ -1,5 +1,3 @@ -from os import getenv - import pytest from mindee import ( @@ -26,14 +24,15 @@ def check_findoc_return(findoc_response: ExtractionResponse): @pytest.mark.pypdfium2 @pytest.mark.integration -def test_pdf_should_extract_splits(): - client = Client() +def test_pdf_should_extract_splits( + v2_client: Client, split_model_id: str, findoc_model_id: str +): split_input = PathInput(V2_PRODUCT_PATH / "split" / "default_sample.pdf") - response = client.enqueue_and_get_result( + response = v2_client.enqueue_and_get_result( SplitResponse, split_input, SplitParameters( - getenv("MINDEE_V2_SE_TESTS_SPLIT_MODEL_ID"), + split_model_id, close_file=False, ), ) @@ -45,12 +44,10 @@ def test_pdf_should_extract_splits(): assert extracted_splits[0].filename == output_files[0] assert extracted_splits[1].filename == output_files[1] - invoice_0 = client.enqueue_and_get_result( + invoice_0 = v2_client.enqueue_and_get_result( ExtractionResponse, extracted_splits[0].as_input_source(), - ExtractionParameters( - getenv("MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID"), close_file=False - ), + ExtractionParameters(findoc_model_id, close_file=False), ) check_findoc_return(invoice_0) extracted_splits.save_all_to_disk(OUTPUT_PATH) diff --git a/tests/v2/product/classification/test_classification_integration.py b/tests/v2/product/classification/test_classification_integration.py index da004023..e110d98c 100644 --- a/tests/v2/product/classification/test_classification_integration.py +++ b/tests/v2/product/classification/test_classification_integration.py @@ -11,7 +11,12 @@ @pytest.fixture(scope="session") def classification_model_id() -> str: """Identifier of the Financial Document model, supplied through an env var.""" - return os.getenv("MINDEE_V2_SE_TESTS_CLASSIFICATION_MODEL_ID") + classification_model_id = os.getenv("MINDEE_V2_SE_TESTS_CLASSIFICATION_MODEL_ID") + if classification_model_id is None: + raise ValueError( + "MINDEE_V2_SE_TESTS_CLASSIFICATION_MODEL_ID environment variable is not set" + ) + return classification_model_id @pytest.fixture(scope="session") diff --git a/tests/v2/product/crop/test_crop_integration.py b/tests/v2/product/crop/test_crop_integration.py index f84ea197..af1e561b 100644 --- a/tests/v2/product/crop/test_crop_integration.py +++ b/tests/v2/product/crop/test_crop_integration.py @@ -1,5 +1,3 @@ -import os - import pytest from mindee.input.path_input import PathInput @@ -8,12 +6,6 @@ from tests.utils import V2_PRODUCT_PATH -@pytest.fixture(scope="session") -def crop_model_id() -> str: - """Identifier of the Financial Document model, supplied through an env var.""" - return os.getenv("MINDEE_V2_SE_TESTS_CROP_MODEL_ID") - - @pytest.fixture(scope="session") def v2_client() -> Client: return Client() diff --git a/tests/v2/product/extraction/test_rag_documents_integration.py b/tests/v2/product/extraction/test_rag_documents_integration.py index 648f50c6..d2ad8ffa 100644 --- a/tests/v2/product/extraction/test_rag_documents_integration.py +++ b/tests/v2/product/extraction/test_rag_documents_integration.py @@ -15,7 +15,7 @@ @pytest.mark.integration @pytest.mark.v2 -def test_rag_document_lifecycle_must_succeed(): +def test_rag_document_lifecycle_must_succeed(v2_client: Client): """Should perform the entire lifecycle of a RAG document.""" extraction_model_id = os.getenv("MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID") if extraction_model_id is None: @@ -23,13 +23,11 @@ def test_rag_document_lifecycle_must_succeed(): "MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID environment variable is not set" ) - client = Client() - input_source = PathInput( V2_PRODUCT_PATH / "extraction" / "financial_document" / "default_sample.jpg" ) post_parameters = RagDocumentUploadParameters(model_id=extraction_model_id) - post_response = client.upload_and_get_rag_document(input_source, post_parameters) + post_response = v2_client.upload_and_get_rag_document(input_source, post_parameters) assert post_response is not None post_annotation = post_response.annotation @@ -47,7 +45,7 @@ def test_rag_document_lifecycle_must_succeed(): "invoice_number" ).guidelines = "koo koo katchoo!" - patch_annotation_response = client.update_rag_annotations( + patch_annotation_response = v2_client.update_rag_annotations( RagDocumentAnnotationParameters( document_id=document_id, annotation=post_annotation, @@ -68,7 +66,7 @@ def test_rag_document_lifecycle_must_succeed(): ) assert patch_annotation.fields.get_simple_field("invoice_number").selected is True - get_response = client.get_ready_rag_document( + get_response = v2_client.get_ready_rag_document( ExtractionRagAnnotationResponse, document_id ) assert get_response is not None @@ -88,7 +86,7 @@ def test_rag_document_lifecycle_must_succeed(): ) assert get_annotation.fields.get_simple_field("invoice_number").selected is True - patch_status_response = client.update_and_get_rag_annotations( + patch_status_response = v2_client.update_and_get_rag_annotations( RagDocumentAnnotationParameters( document_id=document_id, status="Active", @@ -97,8 +95,8 @@ def test_rag_document_lifecycle_must_succeed(): assert patch_status_response is not None assert patch_status_response.status == "Active" - delete_response = client.delete_extraction_rag_document(document_id) + delete_response = v2_client.delete_extraction_rag_document(document_id) assert delete_response is True with pytest.raises(MindeeHTTPErrorV2): - client.get_rag_document(ExtractionRagAnnotationResponse, document_id) + v2_client.get_rag_document(ExtractionRagAnnotationResponse, document_id) diff --git a/tests/v2/product/ocr/test_ocr_integration.py b/tests/v2/product/ocr/test_ocr_integration.py index 7fb0b6bd..93a11250 100644 --- a/tests/v2/product/ocr/test_ocr_integration.py +++ b/tests/v2/product/ocr/test_ocr_integration.py @@ -12,12 +12,10 @@ @pytest.fixture(scope="session") def ocr_model_id() -> str: """Identifier of the Financial Document model, supplied through an env var.""" - return os.getenv("MINDEE_V2_SE_TESTS_OCR_MODEL_ID") - - -@pytest.fixture(scope="session") -def v2_client() -> Client: - return Client() + ocr_model_id = os.getenv("MINDEE_V2_SE_TESTS_OCR_MODEL_ID") + if ocr_model_id is None: + raise ValueError("MINDEE_V2_SE_TESTS_OCR_MODEL_ID environment variable not set") + return ocr_model_id @pytest.mark.integration diff --git a/tests/v2/product/split/test_split_integration.py b/tests/v2/product/split/test_split_integration.py index 0a9c03ae..2c4d43d9 100644 --- a/tests/v2/product/split/test_split_integration.py +++ b/tests/v2/product/split/test_split_integration.py @@ -1,5 +1,3 @@ -import os - import pytest from mindee.input.path_input import PathInput @@ -8,17 +6,6 @@ from tests.utils import V2_PRODUCT_PATH -@pytest.fixture(scope="session") -def split_model_id() -> str: - """Identifier of the Financial Document model, supplied through an env var.""" - return os.getenv("MINDEE_V2_SE_TESTS_SPLIT_MODEL_ID") - - -@pytest.fixture(scope="session") -def v2_client() -> Client: - return Client() - - @pytest.mark.integration @pytest.mark.v2 def test_split_default_sample(v2_client: Client, split_model_id: str): diff --git a/tests/v2/search/test_model_search_integration.py b/tests/v2/search/test_model_search_integration.py index dd6e9275..104cbe97 100644 --- a/tests/v2/search/test_model_search_integration.py +++ b/tests/v2/search/test_model_search_integration.py @@ -4,11 +4,6 @@ from mindee.v2.search.models.model_search_parameters import ModelSearchParameters -@pytest.fixture(scope="session") -def v2_client() -> Client: - return Client() - - @pytest.mark.integration @pytest.mark.v2 def test_search_must_have_results(v2_client: Client): diff --git a/tests/v2/search/test_rag_document_search_integration.py b/tests/v2/search/test_rag_document_search_integration.py index 5b17e653..7fd7f04f 100644 --- a/tests/v2/search/test_rag_document_search_integration.py +++ b/tests/v2/search/test_rag_document_search_integration.py @@ -1,23 +1,9 @@ -import os - import pytest from mindee.v2.client import Client from mindee.v2.search.rag_documents import RagDocumentSearchParameters -@pytest.fixture(scope="session") -def v2_client() -> Client: - return Client() - - -@pytest.fixture(scope="session") -def findoc_model_id() -> str: - findoc_model_id = os.getenv("MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID") - assert findoc_model_id, "MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID must be set" - return findoc_model_id - - @pytest.mark.integration @pytest.mark.v2 def test_search_must_have_results(v2_client: Client, findoc_model_id: str): diff --git a/tests/v2/test_client_integration.py b/tests/v2/test_client_integration.py index decdc981..b8aa0907 100644 --- a/tests/v2/test_client_integration.py +++ b/tests/v2/test_client_integration.py @@ -21,11 +21,6 @@ from tests.utils import FILE_TYPES_PATH, V2_PRODUCT_PATH -@pytest.fixture(scope="session") -def v2_client() -> Client: - return Client() - - def _basic_assert_success( response: ExtractionResponse, page_count: int, model_id: str ) -> None: @@ -423,7 +418,7 @@ def log_request(request: httpx.Request): @pytest.mark.v2 @pytest.mark.integration -def test_http2_client(findoc_model_id) -> None: +def test_http2_client(findoc_model_id: str) -> None: httpx_client = httpx.Client(http2=True) with Client(http_client=httpx_client) as client: input_source = PathInput( From 7caac5b49f6670c013aec055a4e0cff1f3e0176a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ianar=C3=A9=20S=C3=A9vi?= Date: Thu, 1 Oct 2026 23:03:55 +0200 Subject: [PATCH 4/7] rework tests and client --- mindee/parsing/common/common_response.py | 9 -- mindee/v2/client.py | 64 ++++++++----- mindee/v2/parsing/__init__.py | 2 + tests/v2/test_client_integration.py | 115 ---------------------- tests/v2/test_webhook_integration.py | 116 +++++++++++++++++++++++ 5 files changed, 160 insertions(+), 146 deletions(-) create mode 100644 tests/v2/test_webhook_integration.py diff --git a/mindee/parsing/common/common_response.py b/mindee/parsing/common/common_response.py index d51789e0..90fbe811 100644 --- a/mindee/parsing/common/common_response.py +++ b/mindee/parsing/common/common_response.py @@ -1,18 +1,9 @@ import json -from enum import Enum from mindee.logger import logger from mindee.parsing.common.string_dict import StringDict -class CommonStatus(str, Enum): - """Response status.""" - - PROCESSING = "Processing" - FAILED = "Failed" - PROCESSED = "Processed" - - class CommonResponse: """Base class for V1 & V2 responses.""" diff --git a/mindee/v2/client.py b/mindee/v2/client.py index f9589b48..e383bc08 100644 --- a/mindee/v2/client.py +++ b/mindee/v2/client.py @@ -9,7 +9,6 @@ from mindee.input.local_input_source import LocalInputSource from mindee.logger import logger from mindee.mindee_http.cancellation_token import CancellationToken -from mindee.parsing.common.common_response import CommonStatus from mindee.v2.client_options.base_annotation_parameters import BaseAnnotationParameters from mindee.v2.client_options.base_product_parameters import BaseProductParameters from mindee.v2.client_options.base_rag_document_upload_parameters import ( @@ -139,19 +138,19 @@ def enqueue_and_get_result( polling_options = PollingOptions() return self._poll_for_result( - enqueue_response, response_type, polling_options, cancellation_token + initial_response=enqueue_response, + response_class=response_type, + polling_options=polling_options, + cancellation_token=cancellation_token, ) - def _poll_for_result( + def _poll_on_job( self, - enqueue_response: JobResponse, - response_type: type[TypeBaseInferenceResponse], + initial_response: JobResponse, polling_options: PollingOptions, + wait_for_webhooks: bool, cancellation_token: CancellationToken | None = None, - ) -> TypeBaseInferenceResponse: - """ - Poll until the inference is finished processing or the max number of attempts is reached. - """ + ) -> JobResponse: if cancellation_token and cancellation_token.is_canceled: raise MindeeError("Request canceled through cancellation token.") sleep(polling_options.initial_delay_sec) @@ -159,28 +158,29 @@ def _poll_for_result( while try_counter < polling_options.max_retries: if cancellation_token and cancellation_token.is_canceled: raise MindeeError("Request canceled through cancellation token.") - job_response = self.get_job(enqueue_response.job.id) + + job_response = self.get_job(initial_response.job.id) assert isinstance(job_response, JobResponse) - if ( - job_response.job.status == CommonStatus.PROCESSED.value - and job_response.job.result_url - ): + if job_response.job.status == "Processed": logger.debug( "Job ID %s completed processing at: %s", job_response.job.id, job_response.job.completed_at, ) - result = self.get_result_from_url( - response_type, job_response.job.result_url - ) - assert isinstance(result, response_type), ( - f'Invalid response type "{type(result)}"' - ) - return result + if wait_for_webhooks: + are_webhooks_done = all( + webhook.status in {"Completed", "Failed"} + for webhook in job_response.job.webhooks + ) + logger.debug("All webhooks are done: %s", are_webhooks_done) + if are_webhooks_done: + return job_response + continue + return job_response # normally the mindee_api will throw on error, this is a fallback - if job_response.job.status == CommonStatus.FAILED.value: + if job_response.job.status == "Failed": if job_response.job.error: detail = job_response.job.error.detail else: @@ -194,6 +194,26 @@ def _poll_for_result( raise MindeeError(f"Couldn't retrieve document after {try_counter + 1} tries.") + def _poll_for_result( + self, + initial_response: JobResponse, + response_class: type[TypeBaseInferenceResponse], + polling_options: PollingOptions, + wait_for_webhooks: bool = False, + cancellation_token: CancellationToken | None = None, + ) -> TypeBaseInferenceResponse: + """ + Poll until the inference is finished processing or the max number of attempts is reached. + """ + job_response = self._poll_on_job( + initial_response, polling_options, wait_for_webhooks, cancellation_token + ) + if not job_response.job.result_url: + raise MindeeError( + "The result URL is undefined. This is a server error, try again later or contact support." + ) + return self.get_result_from_url(response_class, job_response.job.result_url) + def upload_rag_document( self, input_source: LocalInputSource, diff --git a/mindee/v2/parsing/__init__.py b/mindee/v2/parsing/__init__.py index a1074474..6a41524b 100644 --- a/mindee/v2/parsing/__init__.py +++ b/mindee/v2/parsing/__init__.py @@ -8,6 +8,7 @@ from mindee.v2.parsing.inference.inference_active_options import InferenceActiveOptions from mindee.v2.parsing.inference.inference_file import InferenceFile from mindee.v2.parsing.inference.inference_model import InferenceModel +from mindee.v2.parsing.job.job import Job from mindee.v2.parsing.job.job_response import JobResponse from mindee.v2.product.extraction.extraction_inference import ExtractionInference from mindee.v2.product.extraction.extraction_response import ExtractionResponse @@ -25,5 +26,6 @@ "InferenceActiveOptions", "InferenceFile", "InferenceModel", + "Job", "JobResponse", ] diff --git a/tests/v2/test_client_integration.py b/tests/v2/test_client_integration.py index b8aa0907..14d09546 100644 --- a/tests/v2/test_client_integration.py +++ b/tests/v2/test_client_integration.py @@ -1,7 +1,5 @@ import os -from datetime import datetime from pathlib import Path -from time import sleep import httpx import pytest @@ -14,10 +12,7 @@ MindeeHTTPErrorV2, ) from mindee.v2.parsing import InferenceActiveOptions -from mindee.v2.parsing.job.job import Job from mindee.v2.product.extraction.extraction_response import ExtractionResponse -from mindee.v2.product.split.params.split_parameters import SplitParameters -from mindee.v2.product.split.split_response import SplitResponse from tests.utils import FILE_TYPES_PATH, V2_PRODUCT_PATH @@ -166,82 +161,6 @@ def test_parse_file_filled_single_page_must_succeed( assert len(supplier_name.locations) == 0 -def _job_is_final(job: Job) -> bool: - if job.status == "Failed": - return True - if job.status != "Processed": - return False - # Webhook deliveries happen asynchronously after processing: - # wait until every webhook has reached a final status as well. - return all(webhook.status in {"Completed", "Failed"} for webhook in job.webhooks) - - -def _enqueue_and_poll_job(v2_client: Client, input_source, params) -> Job: - """Enqueue a document and poll until the job and its webhooks reach a final status.""" - job = v2_client.enqueue(input_source, params).job - for _ in range(60): - sleep(2) - job = v2_client.get_job(job.id).job - if _job_is_final(job): - break - return job - - -def _assert_webhook_job_success(job: Job, webhook_ids: list) -> None: - assert job.status == "Processed" - assert isinstance(job.completed_at, datetime) - assert job.error is None - assert len(job.webhooks) == len(webhook_ids) - assert all(webhook.status in {"Completed", "Failed"} for webhook in job.webhooks) - assert {webhook.id for webhook in job.webhooks} == set(webhook_ids) - - -@pytest.mark.integration -@pytest.mark.v2 -def test_extraction_with_two_webhooks_must_complete_and_succeed( - v2_client: Client, findoc_model_id: str -) -> None: - webhook_ids = [ - "9a0d88be-6913-484d-a019-9d2e16e2d3b9", - "32286ed9-fe40-4f42-bdc5-2f8496c5641a", - ] - - input_source = PathInput( - V2_PRODUCT_PATH / "extraction" / "financial_document" / "default_sample.jpg" - ) - params = ExtractionParameters(model_id=findoc_model_id, webhook_ids=webhook_ids) - - job = _enqueue_and_poll_job(v2_client, input_source, params) - _assert_webhook_job_success(job, webhook_ids) - - response = v2_client.get_result_from_url(ExtractionResponse, job.result_url) - assert response.inference is not None - assert response.inference.result is not None - assert response.inference.result.fields["supplier_name"].value == "John Smith" - - -@pytest.mark.integration -@pytest.mark.v2 -def test_split_with_two_webhooks_must_complete_and_succeed( - v2_client: Client, split_model_id: str -) -> None: - webhook_ids = [ - "b8fdfea3-24b6-438a-a6ca-7cd8c87a8875", - "d5bf36a9-1301-42c7-95be-03dc20d8f10e", - ] - - input_source = PathInput(V2_PRODUCT_PATH / "split" / "default_sample.pdf") - params = SplitParameters(model_id=split_model_id, webhook_ids=webhook_ids) - - job = _enqueue_and_poll_job(v2_client, input_source, params) - _assert_webhook_job_success(job, webhook_ids) - - response = v2_client.get_result_from_url(SplitResponse, job.result_url) - assert response.inference is not None - assert response.inference.result is not None - assert len(response.inference.result.splits) == 2 - - @pytest.mark.integration @pytest.mark.v2 def test_invalid_uuid_must_throw_error(v2_client: Client) -> None: @@ -286,40 +205,6 @@ def test_unknown_model_must_throw_error(v2_client: Client) -> None: assert isinstance(exc.errors, list) -@pytest.mark.integration -@pytest.mark.v2 -def test_unknown_webhook_ids_must_throw_error( - v2_client: Client, findoc_model_id: str -) -> None: - """ - Using an unknown webhook identifier must trigger an error. - """ - input_path: Path = FILE_TYPES_PATH / "pdf" / "blank_1.pdf" - - input_source = PathInput(input_path) - params = ExtractionParameters( - model_id=findoc_model_id, - webhook_ids=[ - "fc405e37-4ba4-4d03-aeba-533a8d1f0f21", - "fc405e37-4ba4-4d03-aeba-533a8d1f0f21", - ], - rag=None, - raw_text=None, - polygon=None, - confidence=None, - ) - - with pytest.raises(MindeeHTTPErrorV2) as e: - v2_client.enqueue(input_source, params) - - exc: MindeeHTTPErrorV2 = e.value - assert exc.status == 422 - assert exc.title is not None - assert exc.code.startswith("422-") - assert isinstance(exc.errors, list) - assert "no matching webhooks" in exc.detail.lower() - - @pytest.mark.integration @pytest.mark.v2 def test_blank_url_input_source_must_succeed( diff --git a/tests/v2/test_webhook_integration.py b/tests/v2/test_webhook_integration.py new file mode 100644 index 00000000..a396546b --- /dev/null +++ b/tests/v2/test_webhook_integration.py @@ -0,0 +1,116 @@ +from datetime import datetime + +import pytest + +from mindee import ( + ExtractionParameters, + ExtractionResponse, + PathInput, + PollingOptions, + SplitParameters, + SplitResponse, +) +from mindee.v2 import Client +from mindee.v2.error import MindeeHTTPErrorV2 +from mindee.v2.parsing import Job +from tests.utils import FILE_TYPES_PATH, V2_PRODUCT_PATH + + +@pytest.mark.integration +@pytest.mark.v2 +def test_unknown_webhook_ids_must_throw_error( + v2_client: Client, findoc_model_id: str +) -> None: + """ + Using an unknown webhook identifier must trigger an error. + """ + input_path = FILE_TYPES_PATH / "pdf" / "blank_1.pdf" + + input_source = PathInput(input_path) + params = ExtractionParameters( + model_id=findoc_model_id, + webhook_ids=[ + "fc405e37-4ba4-4d03-aeba-533a8d1f0f21", + "fc405e37-4ba4-4d03-aeba-533a8d1f0f21", + ], + rag=None, + raw_text=None, + polygon=None, + confidence=None, + ) + + with pytest.raises(MindeeHTTPErrorV2) as e: + v2_client.enqueue(input_source, params) + + exc: MindeeHTTPErrorV2 = e.value + assert exc.status == 422 + assert exc.title is not None + assert exc.code.startswith("422-") + assert isinstance(exc.errors, list) + assert "no matching webhooks" in exc.detail.lower() + + +def _enqueue_and_poll_job(v2_client: Client, input_source, params) -> Job: + """Enqueue a document and poll until the job and its webhooks reach a final status.""" + initial_response = v2_client.enqueue(input_source, params) + job_response = v2_client._poll_on_job( + initial_response=initial_response, + polling_options=PollingOptions(), + wait_for_webhooks=True, + ) + return job_response.job + + +def _assert_webhook_job_success(job: Job, webhook_ids: list) -> None: + assert job.status == "Processed" + assert isinstance(job.completed_at, datetime) + assert job.error is None + assert len(job.webhooks) == len(webhook_ids) + assert all(webhook.status in {"Completed", "Failed"} for webhook in job.webhooks) + assert {webhook.id for webhook in job.webhooks} == set(webhook_ids) + + +@pytest.mark.integration +@pytest.mark.v2 +def test_extraction_with_two_webhooks_must_complete_and_succeed( + v2_client: Client, findoc_model_id: str +) -> None: + webhook_ids = [ + "9a0d88be-6913-484d-a019-9d2e16e2d3b9", + "32286ed9-fe40-4f42-bdc5-2f8496c5641a", + ] + + input_source = PathInput( + V2_PRODUCT_PATH / "extraction" / "financial_document" / "default_sample.jpg" + ) + params = ExtractionParameters(model_id=findoc_model_id, webhook_ids=webhook_ids) + + job = _enqueue_and_poll_job(v2_client, input_source, params) + _assert_webhook_job_success(job, webhook_ids) + + response = v2_client.get_result_from_url(ExtractionResponse, job.result_url) + assert response.inference is not None + assert response.inference.result is not None + assert response.inference.result.fields["supplier_name"].value == "John Smith" + + +@pytest.mark.integration +@pytest.mark.v2 +def test_split_with_two_webhooks_must_complete_and_succeed( + v2_client: Client, split_model_id: str +) -> None: + webhook_ids = [ + "b8fdfea3-24b6-438a-a6ca-7cd8c87a8875", + "d5bf36a9-1301-42c7-95be-03dc20d8f10e", + ] + + input_source = PathInput(V2_PRODUCT_PATH / "split" / "default_sample.pdf") + params = SplitParameters(model_id=split_model_id, webhook_ids=webhook_ids) + + job = _enqueue_and_poll_job(v2_client, input_source, params) + _assert_webhook_job_success(job, webhook_ids) + + response = v2_client.get_result_from_url(SplitResponse, job.result_url) + assert response.inference is not None + assert response.inference.result is not None + assert len(response.inference.result.splits) == 2 From 3b478b153a5b55f17e8acb4dc2ca38a72bdf4571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ianar=C3=A9=20S=C3=A9vi?= Date: Fri, 2 Oct 2026 10:34:59 +0200 Subject: [PATCH 5/7] better polling options logic --- .github/workflows/_test-units.yml | 8 +++++++ mindee/v2/client.py | 36 ++++++++++++++++++++----------- tests/v2/conftest.py | 10 ++++----- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/.github/workflows/_test-units.yml b/.github/workflows/_test-units.yml index 5d7972ed..67cb11fe 100644 --- a/.github/workflows/_test-units.yml +++ b/.github/workflows/_test-units.yml @@ -6,6 +6,14 @@ name: Test on: workflow_call: +env: + MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID: ${{ secrets.MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID }} + MINDEE_V2_SE_TESTS_BLANK_PDF_URL: ${{ secrets.MINDEE_V2_SE_TESTS_BLANK_PDF_URL }} + 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: pytest: name: Run Unit Tests diff --git a/mindee/v2/client.py b/mindee/v2/client.py index e383bc08..7a58822a 100644 --- a/mindee/v2/client.py +++ b/mindee/v2/client.py @@ -127,16 +127,17 @@ def enqueue_and_get_result( :return: A valid inference response. """ - enqueue_response = self.enqueue(input_source, params) - logger.debug( - "Successfully enqueued document with job ID: %s", enqueue_response.job.id - ) if params.polling_options: polling_options = params.polling_options polling_options.validate_settings() else: polling_options = PollingOptions() + enqueue_response = self.enqueue(input_source, params) + logger.debug( + "Successfully enqueued document with job ID: %s", enqueue_response.job.id + ) + return self._poll_for_result( initial_response=enqueue_response, response_class=response_type, @@ -173,9 +174,12 @@ def _poll_on_job( webhook.status in {"Completed", "Failed"} for webhook in job_response.job.webhooks ) - logger.debug("All webhooks are done: %s", are_webhooks_done) if are_webhooks_done: + logger.debug("All webhooks are completed.") return job_response + logger.debug("Not all webhooks are completed.") + try_counter += 1 + sleep(polling_options.delay_sec) continue return job_response @@ -236,11 +240,14 @@ def upload_and_get_rag_document( """ Add a document to the RAG database and return the initial annotation. """ + if polling_options is None: + polling_options = PollingOptions() + else: + polling_options = PollingOptions() + initial_response = self.upload_rag_document(input_source, parameters) if initial_response.status != "Processing": return initial_response - if polling_options is None: - polling_options = PollingOptions() return self._poll_for_rag_document( initial_response, polling_options, cancellation_token ) @@ -265,11 +272,14 @@ def get_ready_rag_document( """ Get a document's info and annotations from the RAG database. """ + if polling_options is None: + polling_options = PollingOptions() + else: + polling_options = PollingOptions() + initial_response = self.get_rag_document(response_type, document_id) if initial_response.status != "Processing": return initial_response - if polling_options is None: - polling_options = PollingOptions() return self._poll_for_rag_document( initial_response, polling_options, cancellation_token ) @@ -293,11 +303,14 @@ def update_and_get_rag_annotations( """ Update a document's annotations in the RAG database. """ + if polling_options is None: + polling_options = PollingOptions() + else: + polling_options = PollingOptions() + initial_response = self.update_rag_annotations(parameters) if initial_response.status != "Processing": return initial_response - if polling_options is None: - polling_options = PollingOptions() return self._poll_for_rag_document( initial_response, polling_options, cancellation_token ) @@ -319,7 +332,6 @@ def _poll_for_rag_document( Poll until the document is finished processing or the max number of attempts is reached. """ logger.info("Polling for RAG document ID: %s", initial_response.id) - polling_options.validate_settings() max_retries = polling_options.max_retries + 1 logger.debug( diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py index 70e3cdf5..96dee3ab 100644 --- a/tests/v2/conftest.py +++ b/tests/v2/conftest.py @@ -12,9 +12,9 @@ def v2_client() -> Client: @pytest.fixture(scope="session") def findoc_model_id() -> str: - """Identifier of the Financial Document model, supplied through an env var.""" + """Identifier of the Financial Document extraction model, supplied through an env var.""" findoc_model_id = os.getenv("MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID", "") - if findoc_model_id is None: + if not findoc_model_id: raise ValueError( "MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID environment variable is not set" ) @@ -25,7 +25,7 @@ def findoc_model_id() -> str: def split_model_id() -> str: """Identifier of the Split model, supplied through an env var.""" split_model_id = os.getenv("MINDEE_V2_SE_TESTS_SPLIT_MODEL_ID", "") - if split_model_id is None: + if not split_model_id: raise ValueError( "MINDEE_V2_SE_TESTS_SPLIT_MODEL_ID environment variable is not set" ) @@ -34,9 +34,9 @@ def split_model_id() -> str: @pytest.fixture(scope="session") def crop_model_id() -> str: - """Identifier of the Financial Document model, supplied through an env var.""" + """Identifier of the Crop model, supplied through an env var.""" crop_model_id = os.getenv("MINDEE_V2_SE_TESTS_CROP_MODEL_ID") - if crop_model_id is None: + if not crop_model_id: raise ValueError( "MINDEE_V2_SE_TESTS_CROP_MODEL_ID environment variable is not set" ) From c64430d60133abef3695dd59fb8328929cd8a82d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ianar=C3=A9=20S=C3=A9vi?= Date: Fri, 2 Oct 2026 10:53:58 +0200 Subject: [PATCH 6/7] :loud_sound: fixes to logging --- mindee/v2/client.py | 63 ++++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/mindee/v2/client.py b/mindee/v2/client.py index 7a58822a..b2b7f343 100644 --- a/mindee/v2/client.py +++ b/mindee/v2/client.py @@ -145,6 +145,22 @@ def enqueue_and_get_result( cancellation_token=cancellation_token, ) + @staticmethod + def __check_webhooks_done(job_response: JobResponse) -> bool: + """ + Checks if all webhooks associated with a job have finished processing. + """ + are_webhooks_done = all( + webhook.status in {"Completed", "Failed"} + for webhook in job_response.job.webhooks + ) + if are_webhooks_done: + logger.debug("All webhooks are completed.") + return True + + logger.debug("Not all webhooks are completed.") + return False + def _poll_on_job( self, initial_response: JobResponse, @@ -152,14 +168,26 @@ def _poll_on_job( wait_for_webhooks: bool, cancellation_token: CancellationToken | None = None, ) -> JobResponse: + """Polls a job until it is processed or the maximum number of tries is reached.""" + logger.debug( + "Waiting %s seconds before attempting to retrieve the result...", + polling_options.initial_delay_sec, + ) + if cancellation_token and cancellation_token.is_canceled: raise MindeeError("Request canceled through cancellation token.") + sleep(polling_options.initial_delay_sec) try_counter = 0 + while try_counter < polling_options.max_retries: if cancellation_token and cancellation_token.is_canceled: raise MindeeError("Request canceled through cancellation token.") + logger.debug( + "Poll attempt %s of %s", try_counter + 1, polling_options.max_retries + ) + job_response = self.get_job(initial_response.job.id) assert isinstance(job_response, JobResponse) @@ -169,19 +197,8 @@ def _poll_on_job( job_response.job.id, job_response.job.completed_at, ) - if wait_for_webhooks: - are_webhooks_done = all( - webhook.status in {"Completed", "Failed"} - for webhook in job_response.job.webhooks - ) - if are_webhooks_done: - logger.debug("All webhooks are completed.") - return job_response - logger.debug("Not all webhooks are completed.") - try_counter += 1 - sleep(polling_options.delay_sec) - continue - return job_response + if not wait_for_webhooks or self.__check_webhooks_done(job_response): + return job_response # normally the mindee_api will throw on error, this is a fallback if job_response.job.status == "Failed": @@ -196,7 +213,7 @@ def _poll_on_job( try_counter += 1 sleep(polling_options.delay_sec) - raise MindeeError(f"Couldn't retrieve document after {try_counter + 1} tries.") + raise MindeeError(f"Couldn't retrieve document after {try_counter} tries.") def _poll_for_result( self, @@ -228,6 +245,7 @@ def upload_rag_document( You will need to poll until the document is ready for use. Add a document to the RAG database. """ + logger.debug("Adding a document to the RAG database") return self.mindee_api.req_post_rag_document(input_source, parameters) def upload_and_get_rag_document( @@ -243,7 +261,7 @@ def upload_and_get_rag_document( if polling_options is None: polling_options = PollingOptions() else: - polling_options = PollingOptions() + polling_options.validate_settings() initial_response = self.upload_rag_document(input_source, parameters) if initial_response.status != "Processing": @@ -260,6 +278,7 @@ def get_rag_document( You will need to poll until the document is ready for use. Get a document's info and annotations from the RAG database. """ + logger.debug("Getting RAG document ID: %s", document_id) return self.mindee_api.req_get_rag_annotation(response_type, document_id) def get_ready_rag_document( @@ -275,7 +294,7 @@ def get_ready_rag_document( if polling_options is None: polling_options = PollingOptions() else: - polling_options = PollingOptions() + polling_options.validate_settings() initial_response = self.get_rag_document(response_type, document_id) if initial_response.status != "Processing": @@ -292,6 +311,7 @@ def update_rag_annotations( You will need to poll until the document is ready for use. Update a document's annotations in the RAG database. """ + logger.debug("Updating RAG document ID: %s", parameters.document_id) return self.mindee_api.req_patch_rag_annotation(parameters) def update_and_get_rag_annotations( @@ -306,7 +326,7 @@ def update_and_get_rag_annotations( if polling_options is None: polling_options = PollingOptions() else: - polling_options = PollingOptions() + polling_options.validate_settings() initial_response = self.update_rag_annotations(parameters) if initial_response.status != "Processing": @@ -320,6 +340,7 @@ def delete_extraction_rag_document(self, document_id: str) -> bool: Delete a document from the RAG database. For extraction models only. """ + logger.debug("Deleting RAG document ID: %s", document_id) return self.mindee_api.req_delete_extraction_rag_document(document_id) def _poll_for_rag_document( @@ -331,11 +352,11 @@ def _poll_for_rag_document( """ Poll until the document is finished processing or the max number of attempts is reached. """ - logger.info("Polling for RAG document ID: %s", initial_response.id) + logger.debug("Polling for RAG document ID: %s", initial_response.id) max_retries = polling_options.max_retries + 1 logger.debug( - "Waiting %s seconds before attempting to retrieve the result...", + "Waiting %s seconds before attempting to retrieve the document...", polling_options.initial_delay_sec, ) @@ -349,7 +370,7 @@ def _poll_for_rag_document( while retry_count < max_retries: if cancellation_token and cancellation_token.is_canceled: raise MindeeError("Request canceled through cancellation token.") - logger.info("Poll attempt %s of %s", retry_count, max_retries) + logger.debug("Poll attempt %s of %s", retry_count, max_retries) response = self.get_rag_document(type(initial_response), document_id) retry_count += 1 @@ -358,7 +379,7 @@ def _poll_for_rag_document( sleep(polling_options.delay_sec) continue if response.status == "Failed": - raise MindeeError("Job failed without an error payload.") + raise MindeeError("RAG failed without an error payload.") return response raise MindeeError(f"RAG polling not complete after {retry_count - 1} attempts.") From 99855eaa24562f5abb6a983dcfdaecff28dc7f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ianar=C3=A9=20S=C3=A9vi?= Date: Fri, 2 Oct 2026 19:34:38 +0200 Subject: [PATCH 7/7] fix for webhook status --- mindee/v2/client.py | 3 +-- tests/v2/test_webhook_integration.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/mindee/v2/client.py b/mindee/v2/client.py index b2b7f343..17d868f0 100644 --- a/mindee/v2/client.py +++ b/mindee/v2/client.py @@ -151,8 +151,7 @@ def __check_webhooks_done(job_response: JobResponse) -> bool: Checks if all webhooks associated with a job have finished processing. """ are_webhooks_done = all( - webhook.status in {"Completed", "Failed"} - for webhook in job_response.job.webhooks + webhook.status != "Processing" for webhook in job_response.job.webhooks ) if are_webhooks_done: logger.debug("All webhooks are completed.") diff --git a/tests/v2/test_webhook_integration.py b/tests/v2/test_webhook_integration.py index a396546b..60ef7ea8 100644 --- a/tests/v2/test_webhook_integration.py +++ b/tests/v2/test_webhook_integration.py @@ -66,7 +66,7 @@ def _assert_webhook_job_success(job: Job, webhook_ids: list) -> None: assert isinstance(job.completed_at, datetime) assert job.error is None assert len(job.webhooks) == len(webhook_ids) - assert all(webhook.status in {"Completed", "Failed"} for webhook in job.webhooks) + assert all(webhook.status in {"Processed", "Failed"} for webhook in job.webhooks) assert {webhook.id for webhook in job.webhooks} == set(webhook_ids)