From 31c887a0f05cf3641d2d4eb8e84cc0beb90ce43c Mon Sep 17 00:00:00 2001 From: Nik <84yk8btb9f@privaterelay.appleid.com> Date: Wed, 16 Sep 2026 15:27:53 +0300 Subject: [PATCH 1/2] Raise an error when requested entities are unsupported for the language The analyzer silently ignored requested entities that no recognizer could serve in the requested language, returning 200 with partial results. In gateway/DLP setups this fails open: an operator adds an entity to the block list, the service keeps answering successfully, and that entity is never scanned. The behavior was inconsistent too, since requesting only unsupported entities already raised an error. RecognizerRegistry.get_recognizers now collects the requested entities that have no matching recognizer for the language and raises a ValueError listing them, so the failure is explicit and actionable. The REST API maps the ValueError to a 400 response instead of a 500. Zero-shot recognizers are unaffected: LMRecognizer requires supported_entities and filters requests against them, and generic consolidation adds GENERIC_PII_ENTITY to that list, so every entity a zero-shot recognizer can serve is reported by get_supported_entities. Note for the reviewer: the behavior change also affects two existing engine tests which used a not-yet-registered entity; they now assert the raised error, which makes the documented contract explicit. --- e2e-tests/tests/test_api_analyzer.py | 19 ++++- presidio-analyzer/app.py | 4 + .../presidio_analyzer/analyzer_engine.py | 4 + .../recognizer_registry.py | 13 +++ .../tests/test_analyzer_engine.py | 81 +++++++++++++------ presidio-analyzer/tests/test_app.py | 64 +++++++++++++++ .../tests/test_recognizer_registry.py | 40 +++++++++ 7 files changed, 199 insertions(+), 26 deletions(-) create mode 100644 presidio-analyzer/tests/test_app.py diff --git a/e2e-tests/tests/test_api_analyzer.py b/e2e-tests/tests/test_api_analyzer.py index 730179ac40..99a02fed7c 100644 --- a/e2e-tests/tests/test_api_analyzer.py +++ b/e2e-tests/tests/test_api_analyzer.py @@ -105,13 +105,28 @@ def test_given_a_incorrect_analyze_language_input_then_return_error(): response_status, response_content = analyze(request_body) - assert response_status == 500 + assert response_status == 400 expected_response = """ {"error": "No matching recognizers were found to serve the request."} """ assert equal_json_strings(expected_response, response_content) +@pytest.mark.api +def test_given_partially_unsupported_entities_then_return_error(): + request_body = """ + { + "text": "codice fiscale RSSMRA85M01H501Q, IBAN IT60X0542811101000000123456", + "language": "en", + "entities": ["IT_FISCAL_CODE", "IBAN_CODE"] + } + """ + + response_status, response_content = analyze(request_body) + + assert response_status == 400 + + @pytest.mark.api def test_given_a_correlationid_analyze_input_then_return_normal_response(): request_body = """ @@ -313,7 +328,7 @@ def test_given_a_unsupported_language_for_supported_entities_then_expect_an_erro expected_response = """ {"error": "No matching recognizers were found to serve the request."} """ - assert response_status == 500 + assert response_status == 400 assert equal_json_strings(expected_response, response_content) diff --git a/presidio-analyzer/app.py b/presidio-analyzer/app.py index 567a796cd0..6e865f4968 100644 --- a/presidio-analyzer/app.py +++ b/presidio-analyzer/app.py @@ -124,6 +124,10 @@ def analyze() -> Tuple[str, int]: self.logger.error(error_msg) return jsonify(error=error_msg), 400 + except ValueError as ve: + self.logger.error(f"Invalid /analyze request. {ve.args[0]}") + return jsonify(error=ve.args[0]), 400 + except Exception as e: self.logger.error( f"A fatal error occurred during execution of " diff --git a/presidio-analyzer/presidio_analyzer/analyzer_engine.py b/presidio-analyzer/presidio_analyzer/analyzer_engine.py index 8637c6524f..29c2824a21 100644 --- a/presidio-analyzer/presidio_analyzer/analyzer_engine.py +++ b/presidio-analyzer/presidio_analyzer/analyzer_engine.py @@ -207,6 +207,10 @@ def analyze( :param regex_flags: regex flags to be used for when allow_list_match is "regex" :param nlp_artifacts: precomputed NlpArtifacts :return: an array of the found entities in the text + :raises ValueError: if an entity in `entities` has no matching recognizer + for the requested language, since it cannot be served by this engine. + To get the servable entities, call + `AnalyzerEngine.get_supported_entities(language)`. :Example: diff --git a/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizer_registry.py b/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizer_registry.py index 130b5a44f3..2e97ce1c4d 100644 --- a/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizer_registry.py +++ b/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizer_registry.py @@ -201,6 +201,8 @@ def get_recognizers( as part of the request :return: A list of the recognizers which supports the supplied entities and language + :raises ValueError: if any entity in `entities` has no recognizer for the + requested language. """ if language is None: raise ValueError("No language provided") @@ -221,6 +223,7 @@ def get_recognizers( if language == rec.supported_language ] else: + unsupported_entities = [] for entity in entities: subset = [ rec @@ -230,6 +233,7 @@ def get_recognizers( ] if not subset: + unsupported_entities.append(entity) logger.warning( "Entity %s doesn't have the corresponding" " recognizer in language : %s", @@ -239,6 +243,15 @@ def get_recognizers( else: to_return.update(set(subset)) + if unsupported_entities: + raise ValueError( + "No matching recognizers were found to serve the " + "request. The following entities are not supported " + f"in language '{language}': {sorted(unsupported_entities)}. " + "Use get_supported_entities to get the list of " + "supported entities for this language." + ) + logger.debug( "Returning a total of %s recognizers", str(len(to_return)), diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index e567c1e5ab..0d0a0745e6 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -312,14 +312,15 @@ def test_when_analyze_added_pattern_recognizer_then_succeed(unit_test_guid): text = "rocket is my favorite transportation" entities = ["CREDIT_CARD", "ROCKET"] - results = analyze_engine.analyze( - correlation_id=unit_test_guid, - text=text, - entities=entities, - language="en", - ) - - assert len(results) == 0 + # The analyzer cannot serve ROCKET before the recognizer is added, + # so requesting it is rejected instead of silently ignored. + with pytest.raises(ValueError): + analyze_engine.analyze( + correlation_id=unit_test_guid, + text=text, + entities=entities, + language="en", + ) # Add a new recognizer for the word "rocket" (case insensitive) mock_recognizer_registry.add_recognizer(pattern_recognizer) @@ -481,14 +482,15 @@ def test_when_removed_pattern_recognizer_then_doesnt_work(unit_test_guid): text = "spaceship is my favorite transportation" entities = ["CREDIT_CARD", "SPACESHIP"] - results = analyze_engine.analyze( - correlation_id=unit_test_guid, - text=text, - entities=entities, - language="en", - ) - - assert len(results) == 0 + # The analyzer cannot serve SPACESHIP before the recognizer is added, + # so requesting it is rejected instead of silently ignored. + with pytest.raises(ValueError): + analyze_engine.analyze( + correlation_id=unit_test_guid, + text=text, + entities=entities, + language="en", + ) # Add a new recognizer for the word "rocket" (case insensitive) mock_recognizer_registry.add_recognizer(pattern_recognizer) @@ -505,14 +507,13 @@ def test_when_removed_pattern_recognizer_then_doesnt_work(unit_test_guid): # Remove recognizer mock_recognizer_registry.remove_recognizer("Spaceship recognizer") # Test again to see we didn't get any results - results = analyze_engine.analyze( - correlation_id=unit_test_guid, - text=text, - entities=entities, - language="en", - ) - - assert len(results) == 0 + with pytest.raises(ValueError): + analyze_engine.analyze( + correlation_id=unit_test_guid, + text=text, + entities=entities, + language="en", + ) def test_when_analyze_with_language_then_returns_correct_response( @@ -1274,3 +1275,35 @@ def test_when_regex_allow_list_is_all_empty_entries_then_results_are_kept(): ) assert filtered == results + + +def test_when_analyze_with_unsupported_entity_then_raise_value_error( + mock_registry, mock_nlp_engine +): + analyzer_engine = AnalyzerEngine( + registry=mock_registry, nlp_engine=mock_nlp_engine + ) + with pytest.raises(ValueError) as err: + analyzer_engine.analyze( + text="My name is David and his number is 4095-2609-9393-4932", + entities=["CREDIT_CARD", "UNSUPPORTED_ENTITY"], + language="en", + ) + + assert "UNSUPPORTED_ENTITY" in str(err.value) + + +def test_when_analyze_with_supported_entities_then_return_exact_results( + mock_registry, mock_nlp_engine +): + analyzer_engine = AnalyzerEngine( + registry=mock_registry, nlp_engine=mock_nlp_engine + ) + results = analyzer_engine.analyze( + text="My name is David and his number is 4095-2609-9393-4932", + entities=["CREDIT_CARD"], + language="en", + ) + + assert len(results) == 1 + assert_result(results[0], "CREDIT_CARD", 35, 54, 1.0) diff --git a/presidio-analyzer/tests/test_app.py b/presidio-analyzer/tests/test_app.py new file mode 100644 index 0000000000..1452270788 --- /dev/null +++ b/presidio-analyzer/tests/test_app.py @@ -0,0 +1,64 @@ +# ruff: noqa: D103,E501 +"""Tests for the analyzer REST API server.""" + +from unittest.mock import MagicMock, patch + +import pytest +from app import create_app + + +@pytest.fixture(scope="module") +def client(): + with ( + patch("app.AnalyzerEngineProvider") as provider, + patch("app.BatchAnalyzerEngine") as batch_engine_cls, + ): + engine = MagicMock() + engine.get_supported_entities.return_value = ["PERSON", "IBAN_CODE"] + provider.return_value.create_engine.return_value = engine + + batch_engine_cls.return_value.analyze_iterator.return_value = iter([[]]) + + app = create_app() + app.testing = True + yield app.test_client(), engine, batch_engine_cls + + +def test_given_supported_entities_then_return_200(client): + test_client, _, _ = client + response = test_client.post( + "/analyze", + json={ + "text": "John Smith", + "language": "en", + "entities": ["PERSON"], + }, + ) + + assert response.status_code == 200 + + +def test_given_unsupported_entity_then_return_400(client): + test_client, engine, batch_engine_cls = client + + def raise_unsupported_error(*args, **kwargs): + raise ValueError( + "No matching recognizers were found to serve the request. " + "The following entities are not supported in language 'en': " + "['UNSUPPORTED_ENTITY']." + ) + + engine.get_supported_entities.return_value = ["PERSON", "IBAN_CODE"] + batch_engine_cls.return_value.analyze_iterator.side_effect = raise_unsupported_error + + response = test_client.post( + "/analyze", + json={ + "text": "John Smith", + "language": "en", + "entities": ["PERSON", "UNSUPPORTED_ENTITY"], + }, + ) + + assert response.status_code == 400 + assert "UNSUPPORTED_ENTITY" in response.get_data(as_text=True) diff --git a/presidio-analyzer/tests/test_recognizer_registry.py b/presidio-analyzer/tests/test_recognizer_registry.py index c61d7aa857..31b16ec60f 100644 --- a/presidio-analyzer/tests/test_recognizer_registry.py +++ b/presidio-analyzer/tests/test_recognizer_registry.py @@ -89,6 +89,46 @@ def test_when_get_recognizers_unsupported_language_then_return( registry.get_recognizers(language="brrrr", entities=["PERSON"]) +def test_when_get_recognizers_with_unsupported_entity_then_raise_error( + mock_recognizer_registry, +): + registry = mock_recognizer_registry + with pytest.raises(ValueError) as err: + registry.get_recognizers( + language="en", entities=["PERSON", "UNSUPPORTED_ENTITY"] + ) + + assert "UNSUPPORTED_ENTITY" in str(err.value) + + +def test_when_get_recognizers_entity_only_supported_in_other_language_then_raise( + mock_recognizer_registry, +): + # ADDRESS is supported in de and he, but not in en. + registry = mock_recognizer_registry + with pytest.raises(ValueError) as err: + registry.get_recognizers(language="en", entities=["ADDRESS"]) + + assert "ADDRESS" in str(err.value) + + +def test_when_get_recognizers_with_ad_hoc_recognizer_then_no_error( + mock_recognizer_registry, +): + registry = mock_recognizer_registry + ad_hoc_recognizer = create_mock_pattern_recognizer( + "en", "UNSUPPORTED_ENTITY", "ad hoc" + ) + recognizers = registry.get_recognizers( + language="en", + entities=["UNSUPPORTED_ENTITY"], + ad_hoc_recognizers=[ad_hoc_recognizer], + ) + + assert len(recognizers) == 1 + assert recognizers[0].name == "ad hoc" + + def test_when_get_recognizers_specific_language_and_entity_then_return_one_result( mock_recognizer_registry, ): From 33e9da837b22a6196b45bf736e7a25453f9f7b99 Mon Sep 17 00:00:00 2001 From: Nik <84yk8btb9f@privaterelay.appleid.com> Date: Thu, 17 Sep 2026 15:59:20 +0300 Subject: [PATCH 2/2] Preserve partial entity results with a deprecation warning --- e2e-tests/tests/test_api_analyzer.py | 14 ++- presidio-analyzer/app.py | 4 - .../presidio_analyzer/analyzer_engine.py | 11 ++- .../recognizer_registry.py | 25 +++-- .../tests/test_analyzer_engine.py | 96 +++++++++--------- presidio-analyzer/tests/test_app.py | 99 +++++++++++-------- .../tests/test_recognizer_registry.py | 45 ++++++--- 7 files changed, 168 insertions(+), 126 deletions(-) diff --git a/e2e-tests/tests/test_api_analyzer.py b/e2e-tests/tests/test_api_analyzer.py index 99a02fed7c..fdb0506acf 100644 --- a/e2e-tests/tests/test_api_analyzer.py +++ b/e2e-tests/tests/test_api_analyzer.py @@ -105,7 +105,7 @@ def test_given_a_incorrect_analyze_language_input_then_return_error(): response_status, response_content = analyze(request_body) - assert response_status == 400 + assert response_status == 500 expected_response = """ {"error": "No matching recognizers were found to serve the request."} """ @@ -113,7 +113,7 @@ def test_given_a_incorrect_analyze_language_input_then_return_error(): @pytest.mark.api -def test_given_partially_unsupported_entities_then_return_error(): +def test_given_partially_unsupported_entities_then_return_supported_results(): request_body = """ { "text": "codice fiscale RSSMRA85M01H501Q, IBAN IT60X0542811101000000123456", @@ -124,7 +124,13 @@ def test_given_partially_unsupported_entities_then_return_error(): response_status, response_content = analyze(request_body) - assert response_status == 400 + expected_response = """ + [ + {"entity_type": "IBAN_CODE", "start": 38, "end": 65, "score": 1.0, "analysis_explanation": null} + ] + """ + assert response_status == 200 + assert equal_json_strings(expected_response, response_content) @pytest.mark.api @@ -328,7 +334,7 @@ def test_given_a_unsupported_language_for_supported_entities_then_expect_an_erro expected_response = """ {"error": "No matching recognizers were found to serve the request."} """ - assert response_status == 400 + assert response_status == 500 assert equal_json_strings(expected_response, response_content) diff --git a/presidio-analyzer/app.py b/presidio-analyzer/app.py index 6e865f4968..567a796cd0 100644 --- a/presidio-analyzer/app.py +++ b/presidio-analyzer/app.py @@ -124,10 +124,6 @@ def analyze() -> Tuple[str, int]: self.logger.error(error_msg) return jsonify(error=error_msg), 400 - except ValueError as ve: - self.logger.error(f"Invalid /analyze request. {ve.args[0]}") - return jsonify(error=ve.args[0]), 400 - except Exception as e: self.logger.error( f"A fatal error occurred during execution of " diff --git a/presidio-analyzer/presidio_analyzer/analyzer_engine.py b/presidio-analyzer/presidio_analyzer/analyzer_engine.py index 29c2824a21..77c5c79579 100644 --- a/presidio-analyzer/presidio_analyzer/analyzer_engine.py +++ b/presidio-analyzer/presidio_analyzer/analyzer_engine.py @@ -207,10 +207,13 @@ def analyze( :param regex_flags: regex flags to be used for when allow_list_match is "regex" :param nlp_artifacts: precomputed NlpArtifacts :return: an array of the found entities in the text - :raises ValueError: if an entity in `entities` has no matching recognizer - for the requested language, since it cannot be served by this engine. - To get the servable entities, call - `AnalyzerEngine.get_supported_entities(language)`. + :raises ValueError: if no recognizers match the requested entities and language. + + Unsupported entities are ignored with a logged warning, while supported + entities are analyzed. Ignoring unsupported entities is deprecated and will + raise an error in a future version. Call + `AnalyzerEngine.get_supported_entities(language)` to find supported entities, + or provide matching ad-hoc recognizers. :Example: diff --git a/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizer_registry.py b/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizer_registry.py index 2e97ce1c4d..4c8eac506a 100644 --- a/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizer_registry.py +++ b/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizer_registry.py @@ -201,8 +201,13 @@ def get_recognizers( as part of the request :return: A list of the recognizers which supports the supplied entities and language - :raises ValueError: if any entity in `entities` has no recognizer for the - requested language. + :raises ValueError: if language is None, entities is None when all_fields + is False, or no recognizers match the request. + + Unsupported entities are ignored with a logged warning. This behavior + is deprecated and will raise an error in a future version. Use + AnalyzerEngine.get_supported_entities(language) to find supported entities, + or provide matching ad-hoc recognizers. """ if language is None: raise ValueError("No language provided") @@ -223,7 +228,6 @@ def get_recognizers( if language == rec.supported_language ] else: - unsupported_entities = [] for entity in entities: subset = [ rec @@ -233,25 +237,18 @@ def get_recognizers( ] if not subset: - unsupported_entities.append(entity) logger.warning( "Entity %s doesn't have the corresponding" - " recognizer in language : %s", + " recognizer in language : %s. Ignoring unsupported entities" + " is deprecated and will raise an error in a future version." + " Use AnalyzerEngine.get_supported_entities(language) to find" + " supported entities, or add a matching recognizer.", entity, language, ) else: to_return.update(set(subset)) - if unsupported_entities: - raise ValueError( - "No matching recognizers were found to serve the " - "request. The following entities are not supported " - f"in language '{language}': {sorted(unsupported_entities)}. " - "Use get_supported_entities to get the list of " - "supported entities for this language." - ) - logger.debug( "Returning a total of %s recognizers", str(len(to_return)), diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index 0d0a0745e6..bae95e1bf8 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -18,8 +18,10 @@ ) from presidio_analyzer.nlp_engine import ( NlpArtifacts, + NoOpNlpEngine, SpacyNlpEngine, ) +from presidio_analyzer.predefined_recognizers import CreditCardRecognizer from presidio_analyzer.recognizer_registry import RecognizerRegistryProvider # noqa: F401 @@ -312,15 +314,14 @@ def test_when_analyze_added_pattern_recognizer_then_succeed(unit_test_guid): text = "rocket is my favorite transportation" entities = ["CREDIT_CARD", "ROCKET"] - # The analyzer cannot serve ROCKET before the recognizer is added, - # so requesting it is rejected instead of silently ignored. - with pytest.raises(ValueError): - analyze_engine.analyze( - correlation_id=unit_test_guid, - text=text, - entities=entities, - language="en", - ) + results = analyze_engine.analyze( + correlation_id=unit_test_guid, + text=text, + entities=entities, + language="en", + ) + + assert len(results) == 0 # Add a new recognizer for the word "rocket" (case insensitive) mock_recognizer_registry.add_recognizer(pattern_recognizer) @@ -482,15 +483,14 @@ def test_when_removed_pattern_recognizer_then_doesnt_work(unit_test_guid): text = "spaceship is my favorite transportation" entities = ["CREDIT_CARD", "SPACESHIP"] - # The analyzer cannot serve SPACESHIP before the recognizer is added, - # so requesting it is rejected instead of silently ignored. - with pytest.raises(ValueError): - analyze_engine.analyze( - correlation_id=unit_test_guid, - text=text, - entities=entities, - language="en", - ) + results = analyze_engine.analyze( + correlation_id=unit_test_guid, + text=text, + entities=entities, + language="en", + ) + + assert len(results) == 0 # Add a new recognizer for the word "rocket" (case insensitive) mock_recognizer_registry.add_recognizer(pattern_recognizer) @@ -507,13 +507,14 @@ def test_when_removed_pattern_recognizer_then_doesnt_work(unit_test_guid): # Remove recognizer mock_recognizer_registry.remove_recognizer("Spaceship recognizer") # Test again to see we didn't get any results - with pytest.raises(ValueError): - analyze_engine.analyze( - correlation_id=unit_test_guid, - text=text, - entities=entities, - language="en", - ) + results = analyze_engine.analyze( + correlation_id=unit_test_guid, + text=text, + entities=entities, + language="en", + ) + + assert len(results) == 0 def test_when_analyze_with_language_then_returns_correct_response( @@ -1277,33 +1278,36 @@ def test_when_regex_allow_list_is_all_empty_entries_then_results_are_kept(): assert filtered == results -def test_when_analyze_with_unsupported_entity_then_raise_value_error( - mock_registry, mock_nlp_engine +@pytest.mark.parametrize( + "entities", + [["CREDIT_CARD"], ["CREDIT_CARD", "UNSUPPORTED_ENTITY"]], +) +def test_when_analyze_with_supported_entities_then_return_exact_results( + entities, + caplog, ): analyzer_engine = AnalyzerEngine( - registry=mock_registry, nlp_engine=mock_nlp_engine + registry=RecognizerRegistry([CreditCardRecognizer()]), + nlp_engine=NoOpNlpEngine(models=[{"lang_code": "en", "model_name": "no_op"}]), ) - with pytest.raises(ValueError) as err: - analyzer_engine.analyze( + with caplog.at_level("WARNING", logger="presidio-analyzer"): + results = analyzer_engine.analyze( text="My name is David and his number is 4095-2609-9393-4932", - entities=["CREDIT_CARD", "UNSUPPORTED_ENTITY"], + entities=entities, language="en", ) - assert "UNSUPPORTED_ENTITY" in str(err.value) - - -def test_when_analyze_with_supported_entities_then_return_exact_results( - mock_registry, mock_nlp_engine -): - analyzer_engine = AnalyzerEngine( - registry=mock_registry, nlp_engine=mock_nlp_engine - ) - results = analyzer_engine.analyze( - text="My name is David and his number is 4095-2609-9393-4932", - entities=["CREDIT_CARD"], - language="en", - ) - assert len(results) == 1 assert_result(results[0], "CREDIT_CARD", 35, 54, 1.0) + warnings = [ + record.message for record in caplog.records if record.levelname == "WARNING" + ] + if "UNSUPPORTED_ENTITY" in entities: + assert len(warnings) == 1 + assert "UNSUPPORTED_ENTITY" in warnings[0] + assert "language : en" in warnings[0] + assert "deprecated" in warnings[0] + assert "will raise an error in a future version" in warnings[0] + assert "get_supported_entities" in warnings[0] + else: + assert warnings == [] diff --git a/presidio-analyzer/tests/test_app.py b/presidio-analyzer/tests/test_app.py index 1452270788..ebc9277455 100644 --- a/presidio-analyzer/tests/test_app.py +++ b/presidio-analyzer/tests/test_app.py @@ -1,64 +1,79 @@ # ruff: noqa: D103,E501 """Tests for the analyzer REST API server.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from app import create_app +from presidio_analyzer import AnalyzerEngine, RecognizerRegistry +from presidio_analyzer.nlp_engine import NoOpNlpEngine +from presidio_analyzer.predefined_recognizers import IbanRecognizer -@pytest.fixture(scope="module") +@pytest.fixture def client(): - with ( - patch("app.AnalyzerEngineProvider") as provider, - patch("app.BatchAnalyzerEngine") as batch_engine_cls, - ): - engine = MagicMock() - engine.get_supported_entities.return_value = ["PERSON", "IBAN_CODE"] + engine = AnalyzerEngine( + registry=RecognizerRegistry([IbanRecognizer()]), + nlp_engine=NoOpNlpEngine(models=[{"lang_code": "en", "model_name": "no_op"}]), + ) + with patch("app.AnalyzerEngineProvider") as provider: provider.return_value.create_engine.return_value = engine - - batch_engine_cls.return_value.analyze_iterator.return_value = iter([[]]) - app = create_app() app.testing = True - yield app.test_client(), engine, batch_engine_cls + yield app.test_client() -def test_given_supported_entities_then_return_200(client): - test_client, _, _ = client - response = test_client.post( - "/analyze", - json={ - "text": "John Smith", - "language": "en", - "entities": ["PERSON"], - }, - ) +@pytest.mark.parametrize("entities", [["IBAN_CODE"], ["IT_FISCAL_CODE", "IBAN_CODE"]]) +def test_given_supported_entities_then_return_exact_results(client, entities, caplog): + with caplog.at_level("WARNING", logger="presidio-analyzer"): + response = client.post( + "/analyze", + json={ + "text": "codice fiscale RSSMRA85M01H501Q, IBAN IT60X0542811101000000123456", + "language": "en", + "entities": entities, + }, + ) assert response.status_code == 200 + assert response.get_json() == [ + { + "entity_type": "IBAN_CODE", + "start": 38, + "end": 65, + "score": 1.0, + "analysis_explanation": None, + } + ] + warnings = [ + record.message for record in caplog.records if record.levelname == "WARNING" + ] + if "IT_FISCAL_CODE" in entities: + assert len(warnings) == 1 + assert "IT_FISCAL_CODE" in warnings[0] + assert "language : en" in warnings[0] + assert "deprecated" in warnings[0] + assert "will raise an error in a future version" in warnings[0] + else: + assert warnings == [] -def test_given_unsupported_entity_then_return_400(client): - test_client, engine, batch_engine_cls = client +def test_given_no_matching_entities_then_return_500(client): + response = client.post( + "/analyze", + json={"text": "test", "language": "en", "entities": ["UNSUPPORTED_ENTITY"]}, + ) - def raise_unsupported_error(*args, **kwargs): - raise ValueError( - "No matching recognizers were found to serve the request. " - "The following entities are not supported in language 'en': " - "['UNSUPPORTED_ENTITY']." - ) + assert response.status_code == 500 + assert response.get_json() == { + "error": "No matching recognizers were found to serve the request." + } - engine.get_supported_entities.return_value = ["PERSON", "IBAN_CODE"] - batch_engine_cls.return_value.analyze_iterator.side_effect = raise_unsupported_error - response = test_client.post( - "/analyze", - json={ - "text": "John Smith", - "language": "en", - "entities": ["PERSON", "UNSUPPORTED_ENTITY"], - }, - ) +def test_given_unsupported_language_for_supported_entities_then_return_500(client): + response = client.get("/supportedentities?language=he") - assert response.status_code == 400 - assert "UNSUPPORTED_ENTITY" in response.get_data(as_text=True) + assert response.status_code == 500 + assert response.get_json() == { + "error": "No matching recognizers were found to serve the request." + } diff --git a/presidio-analyzer/tests/test_recognizer_registry.py b/presidio-analyzer/tests/test_recognizer_registry.py index 31b16ec60f..dccec77b48 100644 --- a/presidio-analyzer/tests/test_recognizer_registry.py +++ b/presidio-analyzer/tests/test_recognizer_registry.py @@ -75,10 +75,14 @@ def test_when_get_recognizers_then_return_all_fields(mock_recognizer_registry): def test_when_get_recognizers_one_language_then_return_one_entity( mock_recognizer_registry, + caplog, ): registry = mock_recognizer_registry recognizers = registry.get_recognizers(language="de", entities=["PERSON"]) - assert len(recognizers) == 1 + assert recognizers == [registry.recognizers[1]] + assert not any( + "Ignoring unsupported entities" in record.message for record in caplog.records + ) def test_when_get_recognizers_unsupported_language_then_return( @@ -89,31 +93,46 @@ def test_when_get_recognizers_unsupported_language_then_return( registry.get_recognizers(language="brrrr", entities=["PERSON"]) -def test_when_get_recognizers_with_unsupported_entity_then_raise_error( +@pytest.mark.parametrize("unsupported_entity", ["UNSUPPORTED_ENTITY", "ADDRESS"]) +def test_when_get_recognizers_with_unsupported_entity_then_warn_and_return_supported( mock_recognizer_registry, + caplog, + unsupported_entity, ): registry = mock_recognizer_registry - with pytest.raises(ValueError) as err: - registry.get_recognizers( - language="en", entities=["PERSON", "UNSUPPORTED_ENTITY"] + with caplog.at_level("WARNING", logger="presidio-analyzer"): + recognizers = registry.get_recognizers( + language="en", entities=["PERSON", unsupported_entity] ) - assert "UNSUPPORTED_ENTITY" in str(err.value) + assert recognizers == [registry.recognizers[0]] + warnings = [ + record.message for record in caplog.records if record.levelname == "WARNING" + ] + assert len(warnings) == 1 + assert unsupported_entity in warnings[0] + assert "language : en" in warnings[0] + assert "deprecated" in warnings[0] + assert "future version" in warnings[0] + assert "raise" in warnings[0] + assert "get_supported_entities" in warnings[0] -def test_when_get_recognizers_entity_only_supported_in_other_language_then_raise( +@pytest.mark.parametrize("entity", ["ADDRESS", "UNSUPPORTED_ENTITY"]) +def test_when_get_recognizers_without_matching_entities_then_raise( mock_recognizer_registry, + entity, ): - # ADDRESS is supported in de and he, but not in en. registry = mock_recognizer_registry with pytest.raises(ValueError) as err: - registry.get_recognizers(language="en", entities=["ADDRESS"]) + registry.get_recognizers(language="en", entities=[entity]) - assert "ADDRESS" in str(err.value) + assert str(err.value) == "No matching recognizers were found to serve the request." def test_when_get_recognizers_with_ad_hoc_recognizer_then_no_error( mock_recognizer_registry, + caplog, ): registry = mock_recognizer_registry ad_hoc_recognizer = create_mock_pattern_recognizer( @@ -125,8 +144,10 @@ def test_when_get_recognizers_with_ad_hoc_recognizer_then_no_error( ad_hoc_recognizers=[ad_hoc_recognizer], ) - assert len(recognizers) == 1 - assert recognizers[0].name == "ad hoc" + assert recognizers == [ad_hoc_recognizer] + assert not any( + "Ignoring unsupported entities" in record.message for record in caplog.records + ) def test_when_get_recognizers_specific_language_and_entity_then_return_one_result(