Skip to content

Commit f594c3d

Browse files
authored
Merge branch 'main' into docs/copilot-instructions-yaml-testing
2 parents 7d6b005 + 753226b commit f594c3d

12 files changed

Lines changed: 532 additions & 53 deletions

docs/development.md

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,52 +16,54 @@ The project is structured so that:
1616
- In the project root directory, you will find common code for using, serving and testing Presidio
1717
as a cluster of services, as well as CI/CD pipelines codebase and documentation.
1818

19-
### Setting up Poetry
19+
### Setting up uv
2020

21-
[Poetry](https://python-poetry.org/) is Python package manager. It is used to manage dependencies and virtual
22-
environments for Presidio services.
23-
Follow these steps when starting to work on a Presidio service with poetry:
21+
[uv](https://docs.astral.sh/uv/) is the Python package manager used to manage dependencies and virtual
22+
environments for Presidio services (the CI installs dependencies the same way).
23+
Follow these steps when starting to work on a Presidio service with uv:
2424

25-
1. Install poetry
25+
1. Install uv
2626

27-
- Using Pip
27+
- Using the standalone installer (you can inspect [install.sh](https://astral.sh/uv/install.sh) before running it)
2828

2929
```sh
30-
pip install poetry
30+
curl -LsSf https://astral.sh/uv/install.sh | sh
3131
```
3232

3333
- Using Homebrew (in MacOS)
3434

3535
```
36-
brew install poetry
36+
brew install uv
3737
```
3838

39-
Additional installation instructions for poetry: <https://python-poetry.org/docs/#installation>
39+
Additional installation instructions for uv: <https://docs.astral.sh/uv/getting-started/installation/>
4040

41-
2. Have poetry create a virtualenv for the project and install all requirements in the pyproject.toml,
41+
2. Have uv create a virtualenv for the project and install all requirements in the pyproject.toml,
4242
including dev requirements.
4343

4444
For example, in the `presidio-analyzer` folder, run:
4545

4646
```
47-
poetry install --all-extras
47+
uv sync --all-extras --group dev
4848
```
4949

50+
Add `--locked` to install the exact locked dependency graph (matching CI behavior).
51+
5052
3. Run all tests:
5153

5254
```
53-
poetry run pytest
55+
uv run pytest
5456
```
5557

5658
4. To run arbitrary scripts within the virtual env, start the command with
57-
`poetry run`. For example:
58-
1. `poetry run ruff check`
59-
2. `poetry run pip freeze`
60-
3. `poetry run python -m spacy download en_core_web_lg`
59+
`uv run`. For example:
60+
1. `uv run ruff check`
61+
2. `uv run pip freeze`
62+
3. `uv run python -m spacy download en_core_web_lg`
6163

62-
Command 3 downloads the default spacy model needed for Presidio Analyzer.`
64+
Command 3 downloads the default spacy model needed for Presidio Analyzer.
6365

64-
#### Alternatively, activate the virtual environment and use the commands using [this method](https://python-poetry.org/docs/basic-usage/#activating-the-virtual-environment).
66+
#### Alternatively, [activate the virtual environment](https://docs.astral.sh/uv/pip/environments/#using-a-virtual-environment) and run the commands without the `uv run` prefix.
6567

6668
### Development guidelines
6769

@@ -93,9 +95,9 @@ use docker-compose ps:
9395
```bash
9496
>docker-compose ps
9597
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
96-
6d5a258d19c2 presidio-anonymizer "/bin/sh -c 'poetry …" 6 minutes ago Up 6 minutes 0.0.0.0:5001->5001/tcp presidio_presidio-anonymizer_1
97-
9aad2b68f93c presidio-analyzer "/bin/sh -c 'poetry …" 2 days ago Up 6 minutes 0.0.0.0:5002->5001/tcp presidio_presidio-analyzer_1
98-
1448dfb3ec2b presidio-image-redactor "/bin/sh -c 'poetry …" 2 seconds ago Up 2 seconds 0.0.0.0:5003->5001/tcp presidio_presidio-image-redactor_1
98+
6d5a258d19c2 presidio-anonymizer "./entrypoint.sh" 6 minutes ago Up 6 minutes 0.0.0.0:5001->5001/tcp presidio_presidio-anonymizer_1
99+
9aad2b68f93c presidio-analyzer "./entrypoint.sh" 2 days ago Up 6 minutes 0.0.0.0:5002->5001/tcp presidio_presidio-analyzer_1
100+
1448dfb3ec2b presidio-image-redactor "./entrypoint.sh" 2 seconds ago Up 2 seconds 0.0.0.0:5003->5001/tcp presidio_presidio-image-redactor_1
99101
```
100102

101103
Edit docker-compose.yml configuration file to change the default ports.
@@ -138,7 +140,7 @@ Running the tests locally can be done in two ways:
138140
1. Using cli, from each service directory, run:
139141

140142
```sh
141-
poetry run pytest
143+
uv run pytest
142144
```
143145

144146
2. Using your IDE.
@@ -202,7 +204,7 @@ Running the e2e-tests locally can be done in two ways:
202204

203205
Presidio services are PEP8 compliant and continuously enforced on style guide issues during the build process using `ruff`, in turn running `flake8` and other linters.
204206

205-
Running ruff locally, using `poetry run ruff check`, you can check for those issues prior to committing a change.
207+
Running ruff locally, using `uv run ruff check`, you can check for those issues prior to committing a change.
206208

207209
Ruff runs linters in addition to the basic `flake8` functionality, Presidio uses linters as part as ruff such as:
208210

presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,14 @@ recognizers:
311311
enabled: false
312312
country_code: kr
313313

314+
- name: KrPassportRecognizer
315+
supported_languages:
316+
- ko
317+
- kr
318+
type: predefined
319+
enabled: false
320+
country_code: kr
321+
314322
- name: SeOrganisationsnummerRecognizer
315323
supported_languages:
316324
- sv

presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/korea/kr_brn_recognizer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def __init__(
5858
supported_language: str = "ko",
5959
supported_entity: str = "KR_BRN",
6060
replacement_pairs: Optional[List[Tuple[str, str]]] = None,
61+
name: Optional[str] = None,
6162
):
6263
self.replacement_pairs = replacement_pairs if replacement_pairs else [("-", "")]
6364

@@ -68,6 +69,7 @@ def __init__(
6869
patterns=patterns,
6970
context=context,
7071
supported_language=supported_language,
72+
name=name,
7173
)
7274

7375
def validate_result(self, pattern_text: str) -> Union[bool, None]:

presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/korea/kr_driver_license_recognizer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def __init__(
7070
supported_language: str = "ko",
7171
supported_entity: str = "KR_DRIVER_LICENSE",
7272
replacement_pairs: Optional[List[Tuple[str, str]]] = None,
73+
name: Optional[str] = None,
7374
):
7475
self.replacement_pairs = (
7576
replacement_pairs if replacement_pairs else [("-", ""), (" ", "")]
@@ -82,6 +83,7 @@ def __init__(
8283
patterns=patterns,
8384
context=context,
8485
supported_language=supported_language,
86+
name=name,
8587
)
8688

8789
def validate_result(self, pattern_text: str) -> bool:

presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/korea/kr_passport_recognizer.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ class KrPassportRecognizer(PatternRecognizer):
1818
the previous passport number format:
1919
- one letter 'M' or 'm' or 'S' or 's' or 'R' or 'r' or 'O' or 'o' or 'D' or 'd'
2020
- eight digits
21+
22+
:param patterns: List of patterns to be used by this recognizer
23+
:param context: List of context words to increase confidence in detection
24+
:param supported_language: Language this recognizer supports
25+
:param supported_entity: The entity this recognizer can detect
26+
:param name: Name of the recognizer
2127
"""
2228

2329
COUNTRY_CODE = "kr"
@@ -48,8 +54,9 @@ def __init__(
4854
self,
4955
patterns: Optional[List[Pattern]] = None,
5056
context: Optional[List[str]] = None,
51-
supported_language: str = "kr",
57+
supported_language: str = "ko",
5258
supported_entity: str = "KR_PASSPORT",
59+
name: Optional[str] = None,
5360
):
5461
patterns = patterns if patterns else self.PATTERNS
5562
context = context if context else self.CONTEXT
@@ -58,4 +65,5 @@ def __init__(
5865
patterns=patterns,
5966
context=context,
6067
supported_language=supported_language,
68+
name=name,
6169
)

presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/us/us_mbi_recognizer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def __init__(
9090
context: Optional[List[str]] = None,
9191
supported_language: str = "en",
9292
supported_entity: str = "US_MBI",
93+
name: Optional[str] = None,
9394
):
9495
patterns = patterns if patterns else self.PATTERNS
9596
context = context if context else self.CONTEXT
@@ -98,4 +99,5 @@ def __init__(
9899
patterns=patterns,
99100
context=context,
100101
supported_language=supported_language,
102+
name=name,
101103
)

presidio-analyzer/presidio_analyzer/predefined_recognizers/third_party/azure_ai_language.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ def __init__(
2525
ta_client: Optional["TextAnalyticsClient"] = None,
2626
azure_ai_key: Optional[str] = None,
2727
azure_ai_endpoint: Optional[str] = None,
28+
name: Optional[str] = None,
29+
context: Optional[List[str]] = None,
2830
):
2931
"""
3032
Wrap the PII detection in Azure AI Language.
@@ -36,15 +38,24 @@ def __init__(
3638
the client will be created using the key and endpoint.
3739
:param azure_ai_key: Azure AI for language key
3840
:param azure_ai_endpoint: Azure AI for language endpoint
41+
:param name: Name of the recognizer. Defaults to "Azure AI Language PII".
42+
:param context: List of context words to boost confidence score.
3943
4044
For more info, see https://learn.microsoft.com/en-us/azure/ai-services/language-service/personally-identifiable-information/overview
4145
"""
4246

47+
# `name` and `context` are appended last so existing positional callers
48+
# are unaffected. They exist because RecognizerListLoader passes both
49+
# when building a recognizer from a registry configuration, and
50+
# RemoteRecognizer.__init__ accepts neither **kwargs nor unknown keys.
51+
# Without them, any YAML entry naming this class raises TypeError before
52+
# the registry finishes loading (#1457, and again after #1800).
4353
super().__init__(
4454
supported_entities=supported_entities,
4555
supported_language=supported_language,
46-
name="Azure AI Language PII",
56+
name=name if name else "Azure AI Language PII",
4757
version="5.2.0",
58+
context=context,
4859
)
4960

5061
is_available = bool(TextAnalyticsClient)

presidio-analyzer/tests/conf/test_azure_ai_language_reco.yaml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
recognizer_registry:
22
global_regex_flags: 26
33
recognizers:
4+
# Plain form: `name` doubles as the class selector and is also passed to the
5+
# constructor. This is the entry shape from #1457.
46
- name: MockAzureAiLanguageRecognizer
57
type: predefined
6-
ta_client: "test" # This is a placeholder for testing purposes
8+
# Rename form: `class_name` selects the class, `name` becomes the instance
9+
# name. This is the documented reason the loader passes `name` at all.
10+
- name: Azure AI Language PII
11+
class_name: MockAzureAiLanguageRecognizer
12+
type: predefined
713

814

915
supported_languages:
@@ -15,4 +21,4 @@ nlp_configuration:
1521
models:
1622
-
1723
lang_code: en
18-
model_name: en_core_web_lg
24+
model_name: en_core_web_lg

presidio-analyzer/tests/test_analyzer_engine_provider.py

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# ruff: noqa: D103,D205,E501,F541,F841,W293
22

3+
import importlib.util
4+
import os
35
import re
46
from pathlib import Path
57
from typing import List
@@ -17,6 +19,27 @@
1719
StanzaRecognizer,
1820
)
1921

22+
def _has_module(name: str) -> bool:
23+
# find_spec imports the parent packages of a dotted name, so a missing
24+
# intermediate raises ModuleNotFoundError instead of returning None --
25+
# "azure.health.deidentification" does exactly that without the ahds extra.
26+
try:
27+
return importlib.util.find_spec(name) is not None
28+
except (ImportError, ValueError):
29+
return False
30+
31+
32+
# Gate the optional-dependency tests below on the module each one actually
33+
# imports. `pytest.importorskip` cannot be used for this: evaluated inside a
34+
# decorator it runs at module import, so a missing extra takes the whole file
35+
# out of collection instead of skipping the two tests that need it.
36+
#
37+
# The bare `azure` name is also too coarse. `azure.*` are namespace packages and
38+
# the `ahds` extra populates them too, so `import azure` can succeed while
39+
# `azure.ai.textanalytics` is absent.
40+
_HAS_TEXT_ANALYTICS = _has_module("azure.ai.textanalytics")
41+
_HAS_HEALTH_DEID = _has_module("azure.health.deidentification")
42+
2043

2144
def get_full_paths(analyzer_yaml, nlp_engine_yaml=None, recognizer_registry_yaml=None):
2245
this_path = Path(__file__).parent.absolute()
@@ -241,9 +264,15 @@ def test_analyzer_engine_provider_with_files_per_provider():
241264

242265

243266
@pytest.mark.skipif(
244-
pytest.importorskip("azure"), reason="Optional dependency not installed"
245-
) # noqa: E501
246-
def test_analyzer_engine_provider_with_azure_ai_language():
267+
not _HAS_TEXT_ANALYTICS, reason="azure-ai-language extra not installed"
268+
)
269+
def test_analyzer_engine_provider_with_azure_ai_language(monkeypatch):
270+
# The constructor builds a client when none is injected, falling back to
271+
# these variables. Nothing here reaches the network: the SDK client is
272+
# constructed locally and `analyze` is overridden below.
273+
monkeypatch.setenv("AZURE_AI_KEY", "test-key")
274+
monkeypatch.setenv("AZURE_AI_ENDPOINT", "https://example.invalid/")
275+
247276
analyzer_yaml, _, _ = get_full_paths(
248277
"conf/test_azure_ai_language_reco.yaml",
249278
)
@@ -261,17 +290,27 @@ def analyze(
261290

262291
analyzer_engine = provider.create_engine()
263292

264-
azure_ai_recognizers = [
265-
rec
293+
names = {
294+
rec.name
266295
for rec in analyzer_engine.registry.recognizers
267-
if rec.name == "Azure AI Language PII"
268-
]
296+
if isinstance(rec, MockAzureAiLanguageRecognizer)
297+
}
269298

270-
assert len(azure_ai_recognizers) == 1
299+
# The plain entry takes its name from the YAML key; the `class_name` entry
300+
# takes the name configured next to it. Both require the constructor to
301+
# accept `name`, which is what regressed after #1800.
302+
assert names == {"MockAzureAiLanguageRecognizer", "Azure AI Language PII"}
271303

272304
assert len(analyzer_engine.analyze("This is a test", language="en")) > 0
273305

274-
@pytest.mark.skipif(pytest.importorskip("azure"), reason="Optional dependency not installed") # noqa: E501
306+
307+
# AzureHealthDeidRecognizer.__init__ builds a client when none is passed, and
308+
# that path reads AHDS_ENDPOINT and raises ValueError without it. The YAML entry
309+
# supplies no client, so the endpoint is as much a precondition as the package.
310+
@pytest.mark.skipif(
311+
not _HAS_HEALTH_DEID or not os.getenv("AHDS_ENDPOINT"),
312+
reason="ahds extra not installed or AHDS_ENDPOINT not set",
313+
)
275314
def test_analyzer_engine_provider_with_ahds():
276315
analyzer_yaml, _, _ = get_full_paths(
277316
"conf/test_ahds_reco.yaml",

presidio-analyzer/tests/test_azure_ai_language_recognizer.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,39 @@ def test_mocked_entities_match_recognizer_results():
8787
assert expected.offset == actual.start
8888
assert expected.length == actual.end - actual.start
8989
assert expected.confidence_score == actual.score
90+
91+
92+
def _mock_ta_client():
93+
try:
94+
importlib.import_module("azure.ai.textanalytics")
95+
except ImportError:
96+
pytest.skip("Skipping test because 'azure.ai.textanalytics' is not installed")
97+
98+
from azure.ai.textanalytics import TextAnalyticsClient
99+
from azure.core.credentials import AzureKeyCredential
100+
101+
return TextAnalyticsClient(endpoint="", credential=AzureKeyCredential(key=""))
102+
103+
104+
def test_name_defaults_to_the_display_name():
105+
recognizer = AzureAILanguageRecognizer(ta_client=_mock_ta_client())
106+
assert recognizer.name == "Azure AI Language PII"
107+
108+
109+
def test_name_can_be_overridden():
110+
# RecognizerListLoader passes `name` for every entry it builds, so the
111+
# constructor has to accept it; a `class_name` entry relies on it winning
112+
# over the hardcoded display name.
113+
recognizer = AzureAILanguageRecognizer(
114+
ta_client=_mock_ta_client(), name="Custom Azure PII"
115+
)
116+
assert recognizer.name == "Custom Azure PII"
117+
118+
119+
def test_context_is_accepted():
120+
# The kwarg #1457 originally failed on. RemoteRecognizer takes it, so the
121+
# subclass has to forward it rather than reject it.
122+
recognizer = AzureAILanguageRecognizer(
123+
ta_client=_mock_ta_client(), context=["patient", "record"]
124+
)
125+
assert recognizer.context == ["patient", "record"]

0 commit comments

Comments
 (0)