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/.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/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 d25351c1..17d868f0 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 ( @@ -128,23 +127,80 @@ def enqueue_and_get_result( :return: A valid inference response. """ - if not params.polling_options: - params.polling_options = PollingOptions() - params.polling_options.validate_settings() + 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, + polling_options=polling_options, + 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 != "Processing" 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, + polling_options: PollingOptions, + 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(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) + + 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) - if job_response.job.status == CommonStatus.FAILED.value: + + if job_response.job.status == "Processed": + logger.debug( + "Job ID %s completed processing at: %s", + job_response.job.id, + job_response.job.completed_at, + ) + 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": if job_response.job.error: detail = job_response.job.error.detail else: @@ -152,26 +208,31 @@ def enqueue_and_get_result( 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 - ): - 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 + try_counter += 1 - sleep(params.polling_options.delay_sec) + sleep(polling_options.delay_sec) + + raise MindeeError(f"Couldn't retrieve document after {try_counter} tries.") - 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, @@ -183,6 +244,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( @@ -195,11 +257,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.validate_settings() + 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 ) @@ -212,6 +277,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( @@ -224,11 +290,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.validate_settings() + 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 ) @@ -241,6 +310,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( @@ -252,11 +322,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.validate_settings() + 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 ) @@ -266,6 +339,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( @@ -277,12 +351,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) - polling_options.validate_settings() + 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, ) @@ -296,7 +369,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 @@ -305,7 +378,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.") 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/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 b5cc6b4a..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,16 +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", "") 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..96dee3ab --- /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 extraction model, supplied through an env var.""" + findoc_model_id = os.getenv("MINDEE_V2_SE_TESTS_FINDOC_MODEL_ID", "") + if not findoc_model_id: + 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 not split_model_id: + 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 Crop model, supplied through an env var.""" + crop_model_id = os.getenv("MINDEE_V2_SE_TESTS_CROP_MODEL_ID") + if not crop_model_id: + 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 35d96e37..14d09546 100644 --- a/tests/v2/test_client_integration.py +++ b/tests/v2/test_client_integration.py @@ -16,11 +16,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: @@ -210,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( @@ -342,7 +303,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( diff --git a/tests/v2/test_webhook_integration.py b/tests/v2/test_webhook_integration.py new file mode 100644 index 00000000..60ef7ea8 --- /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 {"Processed", "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