presidio-structured 0.0.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: presidio_structured
3
+ Version: 0.0.7
4
+ Summary: Presidio structured package - analyzes and anonymizes structured and semi-structured data.
5
+ License-Expression: MIT
6
+ Keywords: presidio_structured
7
+ Author: Presidio
8
+ Author-email: presidio@microsoft.com
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Dist: click (>=8.1.0,<9.0.0)
17
+ Requires-Dist: pandas (>=1.5.2,<4.0.0)
18
+ Requires-Dist: presidio-analyzer (>=2.2.0,<3.0.0)
19
+ Requires-Dist: presidio-anonymizer (>=2.2.0,<3.0.0)
20
+ Project-URL: Homepage, https://github.com/data-privacy-stack/presidio
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Presidio structured
24
+
25
+ ## Description
26
+
27
+ The Presidio structured package is a flexible and customizable framework designed to identify and protect structured sensitive data. This tool extends the capabilities of Presidio, focusing on structured data formats such as tabular formats and semi-structured formats (JSON). It leverages the detection capabilities of Presidio-Analyzer to identify columns or keys containing personally identifiable information (PII), and establishes a mapping between these column/keys names and the detected PII entities. Following the detection, Presidio-Anonymizer is used to apply de-identification techniques to each value in columns identified as containing PII, ensuring the sensitive data is appropriately protected.
28
+
29
+ ## Installation
30
+
31
+ ### As a python package
32
+
33
+ To install the `presidio-structured` package, run the following command:
34
+
35
+ ```sh
36
+ pip install presidio-structured
37
+ ```
38
+
39
+ ### Getting started
40
+
41
+ Anonymizing Data Frames:
42
+
43
+ ```py
44
+ import pandas as pd
45
+ from presidio_structured import StructuredEngine, PandasAnalysisBuilder
46
+ from presidio_anonymizer.entities import OperatorConfig
47
+ from faker import Faker # optionally using faker as an example
48
+
49
+ # Initialize the engine with a Pandas data processor (default)
50
+ pandas_engine = StructuredEngine()
51
+
52
+ # Create a sample DataFrame
53
+ sample_df = pd.DataFrame({'name': ['John Doe', 'Jane Smith'], 'email': ['john.doe@example.com', 'jane.smith@example.com']})
54
+
55
+ # Generate a tabular analysis which describes PII entities in the DataFrame.
56
+ tabular_analysis = PandasAnalysisBuilder().generate_analysis(sample_df)
57
+
58
+ # Define anonymization operators
59
+ fake = Faker()
60
+ operators = {
61
+ "PERSON": OperatorConfig("replace", {"new_value": "REDACTED"}),
62
+ "EMAIL_ADDRESS": OperatorConfig("custom", {"lambda": lambda x: fake.safe_email()})
63
+ }
64
+
65
+ # Anonymize DataFrame
66
+ anonymized_df = pandas_engine.anonymize(sample_df, tabular_analysis, operators=operators)
67
+ print(anonymized_df)
68
+ ```
69
+
70
+ ## More information
71
+
72
+ - [Docs](https://presidio.dataprivacystack.org/structured/)
73
+ - [Samples](https://github.com/data-privacy-stack/presidio/blob/main/docs/samples/python/example_structured.ipynb)
74
+ - [Join the discussion](https://github.com/data-privacy-stack/presidio/discussions?discussions_q=structured)
75
+ - [Review issues on Github](https://github.com/data-privacy-stack/presidio/issues?q=is%3Aissue+label%3Astructured-data)
76
+
@@ -0,0 +1,53 @@
1
+ # Presidio structured
2
+
3
+ ## Description
4
+
5
+ The Presidio structured package is a flexible and customizable framework designed to identify and protect structured sensitive data. This tool extends the capabilities of Presidio, focusing on structured data formats such as tabular formats and semi-structured formats (JSON). It leverages the detection capabilities of Presidio-Analyzer to identify columns or keys containing personally identifiable information (PII), and establishes a mapping between these column/keys names and the detected PII entities. Following the detection, Presidio-Anonymizer is used to apply de-identification techniques to each value in columns identified as containing PII, ensuring the sensitive data is appropriately protected.
6
+
7
+ ## Installation
8
+
9
+ ### As a python package
10
+
11
+ To install the `presidio-structured` package, run the following command:
12
+
13
+ ```sh
14
+ pip install presidio-structured
15
+ ```
16
+
17
+ ### Getting started
18
+
19
+ Anonymizing Data Frames:
20
+
21
+ ```py
22
+ import pandas as pd
23
+ from presidio_structured import StructuredEngine, PandasAnalysisBuilder
24
+ from presidio_anonymizer.entities import OperatorConfig
25
+ from faker import Faker # optionally using faker as an example
26
+
27
+ # Initialize the engine with a Pandas data processor (default)
28
+ pandas_engine = StructuredEngine()
29
+
30
+ # Create a sample DataFrame
31
+ sample_df = pd.DataFrame({'name': ['John Doe', 'Jane Smith'], 'email': ['john.doe@example.com', 'jane.smith@example.com']})
32
+
33
+ # Generate a tabular analysis which describes PII entities in the DataFrame.
34
+ tabular_analysis = PandasAnalysisBuilder().generate_analysis(sample_df)
35
+
36
+ # Define anonymization operators
37
+ fake = Faker()
38
+ operators = {
39
+ "PERSON": OperatorConfig("replace", {"new_value": "REDACTED"}),
40
+ "EMAIL_ADDRESS": OperatorConfig("custom", {"lambda": lambda x: fake.safe_email()})
41
+ }
42
+
43
+ # Anonymize DataFrame
44
+ anonymized_df = pandas_engine.anonymize(sample_df, tabular_analysis, operators=operators)
45
+ print(anonymized_df)
46
+ ```
47
+
48
+ ## More information
49
+
50
+ - [Docs](https://presidio.dataprivacystack.org/structured/)
51
+ - [Samples](https://github.com/data-privacy-stack/presidio/blob/main/docs/samples/python/example_structured.ipynb)
52
+ - [Join the discussion](https://github.com/data-privacy-stack/presidio/discussions?discussions_q=structured)
53
+ - [Review issues on Github](https://github.com/data-privacy-stack/presidio/issues?q=is%3Aissue+label%3Astructured-data)
@@ -0,0 +1,26 @@
1
+ """presidio-structured root module."""
2
+
3
+ import logging
4
+
5
+ from .analysis_builder import JsonAnalysisBuilder, PandasAnalysisBuilder
6
+ from .config import StructuredAnalysis
7
+ from .data import (
8
+ CsvReader,
9
+ JsonDataProcessor,
10
+ JsonReader,
11
+ PandasDataProcessor,
12
+ )
13
+ from .structured_engine import StructuredEngine
14
+
15
+ logging.getLogger("presidio-structured").addHandler(logging.NullHandler())
16
+
17
+ __all__ = [
18
+ "StructuredEngine",
19
+ "JsonAnalysisBuilder",
20
+ "PandasAnalysisBuilder",
21
+ "StructuredAnalysis",
22
+ "CsvReader",
23
+ "JsonReader",
24
+ "PandasDataProcessor",
25
+ "JsonDataProcessor",
26
+ ]
@@ -0,0 +1,418 @@
1
+ import logging
2
+ from abc import ABC, abstractmethod
3
+ from collections import Counter
4
+ from collections.abc import Iterable
5
+ from typing import Dict, Iterator, List, Optional, Union
6
+
7
+ from pandas import DataFrame
8
+ from presidio_analyzer import (
9
+ AnalyzerEngine,
10
+ BatchAnalyzerEngine,
11
+ DictAnalyzerResult,
12
+ RecognizerResult,
13
+ )
14
+
15
+ from presidio_structured.config import StructuredAnalysis
16
+
17
+ NON_PII_ENTITY_TYPE = "NON_PII"
18
+
19
+ logger = logging.getLogger("presidio-structured")
20
+
21
+
22
+ class AnalysisBuilder(ABC):
23
+ """Abstract base class for a configuration generator."""
24
+
25
+ def __init__(
26
+ self,
27
+ analyzer: Optional[AnalyzerEngine] = None,
28
+ analyzer_score_threshold: Optional[float] = None,
29
+ n_process: int = 1,
30
+ batch_size: int = 1
31
+ ) -> None:
32
+ """Initialize the configuration generator.
33
+
34
+ :param analyzer: AnalyzerEngine instance
35
+ :param analyzer_score_threshold: threshold for filtering out results
36
+ :param batch_size: Batch size to process in a single iteration
37
+ :param n_process: Number of processors to use. Defaults to `1`
38
+ """
39
+ default_score_threshold = (
40
+ analyzer_score_threshold if analyzer_score_threshold is not None else 0
41
+ )
42
+ self.analyzer = (
43
+ AnalyzerEngine(default_score_threshold=default_score_threshold)
44
+ if analyzer is None
45
+ else analyzer
46
+ )
47
+ self.batch_analyzer = BatchAnalyzerEngine(analyzer_engine=self.analyzer)
48
+ self.n_process = n_process
49
+ self.batch_size = batch_size
50
+
51
+ @abstractmethod
52
+ def generate_analysis(
53
+ self,
54
+ data: Union[Dict, DataFrame],
55
+ language: str = "en",
56
+ score_threshold: Optional[float] = None,
57
+ ) -> StructuredAnalysis:
58
+ """
59
+ Abstract method to generate a configuration from the given data.
60
+
61
+ :param data: The input data. Can be a dictionary or DataFrame instance.
62
+ :return: The generated configuration.
63
+ """
64
+ pass
65
+
66
+ def _remove_low_scores(
67
+ self,
68
+ key_recognizer_result_map: Dict[str, RecognizerResult],
69
+ score_threshold: Optional[float] = None,
70
+ ) -> Dict[str, RecognizerResult]:
71
+ """
72
+ Remove results for which the confidence is lower than the threshold.
73
+
74
+ :param results: Dict of column names to RecognizerResult
75
+ :param score_threshold: float value for minimum possible confidence
76
+ :return: List[RecognizerResult]
77
+ """
78
+ if score_threshold is None:
79
+ score_threshold = self.analyzer.default_score_threshold
80
+
81
+ new_key_recognizer_result_map = {}
82
+ for column, result in key_recognizer_result_map.items():
83
+ if result.score >= score_threshold:
84
+ new_key_recognizer_result_map[column] = result
85
+
86
+ return new_key_recognizer_result_map
87
+
88
+
89
+ class JsonAnalysisBuilder(AnalysisBuilder):
90
+ """Concrete configuration generator for JSON data."""
91
+
92
+ def generate_analysis(
93
+ self,
94
+ data: Dict,
95
+ language: str = "en",
96
+ ) -> StructuredAnalysis:
97
+ """
98
+ Generate a configuration from the given JSON data.
99
+
100
+ :param data: The input JSON data.
101
+ :return: The generated configuration.
102
+ """
103
+ logger.debug("Starting JSON BatchAnalyzer analysis")
104
+ analyzer_results = self.batch_analyzer.analyze_dict(
105
+ input_dict=data,
106
+ language=language,
107
+ n_process=self.n_process,
108
+ batch_size=self.batch_size
109
+ )
110
+
111
+ key_recognizer_result_map = self._generate_analysis_from_results_json(
112
+ analyzer_results
113
+ )
114
+
115
+ key_entity_map = {
116
+ key: result.entity_type for key, result in key_recognizer_result_map.items()
117
+ }
118
+
119
+ return StructuredAnalysis(entity_mapping=key_entity_map)
120
+
121
+ def _generate_analysis_from_results_json(
122
+ self, analyzer_results: Iterator[DictAnalyzerResult], prefix: str = ""
123
+ ) -> Dict[str, RecognizerResult]:
124
+ """
125
+ Generate a configuration from the given analyzer results. Always uses the first recognizer result if there are more than one.
126
+
127
+ :param analyzer_results: The analyzer results.
128
+ :param prefix: The prefix for the configuration keys.
129
+ :return: The generated configuration.
130
+ """ # noqa: E501
131
+ key_recognizer_result_map = {}
132
+
133
+ if not isinstance(analyzer_results, Iterable):
134
+ logger.debug(
135
+ "No analyzer results found, returning empty StructuredAnalysis"
136
+ )
137
+ return key_recognizer_result_map
138
+
139
+ for result in analyzer_results:
140
+ current_key = prefix + result.key
141
+
142
+ if isinstance(result.value, dict) and isinstance(
143
+ result.recognizer_results, Iterator
144
+ ):
145
+ nested_mappings = self._generate_analysis_from_results_json(
146
+ result.recognizer_results, prefix=current_key + "."
147
+ )
148
+ key_recognizer_result_map.update(nested_mappings)
149
+ first_recognizer_result = next(iter(result.recognizer_results), None)
150
+ if isinstance(first_recognizer_result, RecognizerResult):
151
+ logger.debug(
152
+ f"Found result with entity {first_recognizer_result.entity_type} \
153
+ in {current_key}"
154
+ )
155
+ key_recognizer_result_map[current_key] = first_recognizer_result
156
+ return key_recognizer_result_map
157
+
158
+
159
+ class TabularAnalysisBuilder(AnalysisBuilder):
160
+ """Placeholder class for generalizing tabular data analysis builders (e.g. PySpark). Only implemented as PandasAnalysisBuilder for now.""" # noqa: E501
161
+
162
+ pass
163
+
164
+
165
+ class PandasAnalysisBuilder(TabularAnalysisBuilder):
166
+ """Concrete configuration generator for tabular data."""
167
+
168
+ entity_selection_strategies = {"highest_confidence", "mixed", "most_common"}
169
+
170
+ def generate_analysis(
171
+ self,
172
+ df: DataFrame,
173
+ n: Optional[int] = None,
174
+ language: str = "en",
175
+ selection_strategy: str = "most_common",
176
+ mixed_strategy_threshold: float = 0.5,
177
+ ) -> StructuredAnalysis:
178
+ """
179
+ Generate a configuration from the given tabular data.
180
+
181
+ :param df: The input tabular data (dataframe).
182
+ :param n: The number of samples to be taken from the dataframe.
183
+ :param language: The language to be used for analysis.
184
+ :param selection_strategy: A string that specifies the entity selection strategy
185
+ ('highest_confidence', 'mixed', or default to most common).
186
+ :param mixed_strategy_threshold: A float value for the threshold to be used in
187
+ the entity selection mixed strategy.
188
+ :return: A StructuredAnalysis object containing the analysis results.
189
+ """
190
+ if not n:
191
+ n = len(df)
192
+ elif n > len(df):
193
+ logger.debug(
194
+ f"Number of samples ({n}) is larger than the number of rows \
195
+ ({len(df)}), using all rows"
196
+ )
197
+ n = len(df)
198
+
199
+ df = df.sample(n, random_state=123)
200
+
201
+ key_recognizer_result_map = self._generate_key_rec_results_map(
202
+ df, language, selection_strategy, mixed_strategy_threshold
203
+ )
204
+
205
+ key_entity_map = {
206
+ key: result.entity_type
207
+ for key, result in key_recognizer_result_map.items()
208
+ if result.entity_type != NON_PII_ENTITY_TYPE
209
+ }
210
+
211
+ return StructuredAnalysis(entity_mapping=key_entity_map)
212
+
213
+ def _generate_key_rec_results_map(
214
+ self,
215
+ df: DataFrame,
216
+ language: str,
217
+ selection_strategy: str = "most_common",
218
+ mixed_strategy_threshold: float = 0.5,
219
+ ) -> Dict[str, RecognizerResult]:
220
+ """
221
+ Find the most common entity in a dataframe column.
222
+
223
+ If more than one entity is found in a cell, the first one is used.
224
+
225
+ :param df: The dataframe where entities will be searched.
226
+ :param language: Language to be used in the analysis engine.
227
+ :param selection_strategy: A string that specifies the entity selection strategy
228
+ ('highest_confidence', 'mixed', or default to most common).
229
+ :param mixed_strategy_threshold: A float value for the threshold to be used in
230
+ the entity selection mixed strategy.
231
+ :return: A dictionary mapping column names to the most common RecognizerResult.
232
+ """
233
+ column_analyzer_results_map = self._batch_analyze_df(df, language)
234
+ key_recognizer_result_map = {}
235
+ for column, analyzer_result in column_analyzer_results_map.items():
236
+ key_recognizer_result_map[column] = self._find_entity_based_on_strategy(
237
+ analyzer_result, selection_strategy, mixed_strategy_threshold
238
+ )
239
+ return key_recognizer_result_map
240
+
241
+ def _batch_analyze_df(
242
+ self, df: DataFrame, language: str
243
+ ) -> Dict[str, List[List[RecognizerResult]]]:
244
+ """
245
+ Analyze each column in the dataframe for entities using the batch analyzer.
246
+
247
+ :param df: The dataframe to be analyzed.
248
+ :param language: The language configuration for the analyzer.
249
+ :return: A dictionary mapping each column name to a \
250
+ list of lists of RecognizerResults.
251
+ """
252
+ column_analyzer_results_map = {}
253
+ for column in df.columns:
254
+ logger.debug(f"Finding most common PII entity for column {column}")
255
+ analyzer_results = self.batch_analyzer.analyze_iterator(
256
+ [val for val in df[column]],
257
+ language=language,
258
+ n_process=self.n_process,
259
+ batch_size=self.batch_size
260
+ )
261
+ column_analyzer_results_map[column] = analyzer_results
262
+
263
+ return column_analyzer_results_map
264
+
265
+ def _find_entity_based_on_strategy(
266
+ self,
267
+ analyzer_results: List[List[RecognizerResult]],
268
+ selection_strategy: str,
269
+ mixed_strategy_threshold: float,
270
+ ) -> RecognizerResult:
271
+ """
272
+ Determine the most suitable entity based on the specified selection strategy.
273
+
274
+ :param analyzer_results: A nested list of RecognizerResult objects from the
275
+ analysis results.
276
+ :param selection_strategy: A string that specifies the entity selection strategy
277
+ ('highest_confidence', 'mixed', or default to most common).
278
+ :return: A RecognizerResult object representing the selected entity based on the
279
+ given strategy.
280
+ """
281
+ if selection_strategy not in self.entity_selection_strategies:
282
+ raise ValueError(
283
+ f"Unsupported entity selection strategy: {selection_strategy}."
284
+ )
285
+
286
+ if not any(analyzer_results):
287
+ return RecognizerResult(
288
+ entity_type=NON_PII_ENTITY_TYPE, start=0, end=1, score=1.0
289
+ )
290
+
291
+ flat_results = self._flatten_results(analyzer_results)
292
+
293
+ # Select the entity based on the desired strategy
294
+ if selection_strategy == "highest_confidence":
295
+ return self._select_highest_confidence_entity(flat_results)
296
+ elif selection_strategy == "mixed":
297
+ return self._select_mixed_strategy_entity(
298
+ flat_results, mixed_strategy_threshold
299
+ )
300
+
301
+ return self._select_most_common_entity(flat_results)
302
+
303
+ def _select_most_common_entity(self, flat_results):
304
+ """
305
+ Select the most common entity from the flattened analysis results.
306
+
307
+ :param flat_results: A list of tuples containing index and RecognizerResult
308
+ objects from the flattened analysis results.
309
+ :return: A RecognizerResult object for the most commonly found entity type.
310
+ """
311
+ # Count occurrences of each entity type
312
+ type_counter = Counter(res.entity_type for _, res in flat_results)
313
+ most_common_type, most_common_count = type_counter.most_common(1)[0]
314
+
315
+ # Calculate the score as the proportion of occurrences
316
+ score = most_common_count / len(flat_results)
317
+
318
+ return RecognizerResult(
319
+ entity_type=most_common_type, start=0, end=1, score=score
320
+ )
321
+
322
+ def _select_highest_confidence_entity(self, flat_results):
323
+ """
324
+ Select the entity with the highest confidence score.
325
+
326
+ :param flat_results: A list of tuples containing index and RecognizerResult
327
+ objects from the flattened analysis results.
328
+ :return: A RecognizerResult object for the entity with the highest confidence
329
+ score.
330
+ """
331
+ score_aggregator = self._aggregate_scores(flat_results)
332
+
333
+ # Find the highest score across all entities
334
+ highest_score = max(
335
+ max(scores) for scores in score_aggregator.values() if scores
336
+ )
337
+
338
+ # Find the entities with the highest score and count their occurrences
339
+ entities_highest_score = {
340
+ entity: scores.count(highest_score)
341
+ for entity, scores in score_aggregator.items()
342
+ if highest_score in scores
343
+ }
344
+
345
+ # Find the entity(ies) with the most number of high scores
346
+ max_occurrences = max(entities_highest_score.values())
347
+ highest_confidence_entities = [
348
+ entity
349
+ for entity, count in entities_highest_score.items()
350
+ if count == max_occurrences
351
+ ]
352
+
353
+ return RecognizerResult(
354
+ entity_type=highest_confidence_entities[0],
355
+ start=0,
356
+ end=1,
357
+ score=highest_score,
358
+ )
359
+
360
+ def _select_mixed_strategy_entity(self, flat_results, mixed_strategy_threshold):
361
+ """
362
+ Select an entity using a mixed strategy.
363
+
364
+ Chooses an entity based on the highest confidence score if it is above the
365
+ threshold. Otherwise, it defaults to the most common entity.
366
+
367
+ :param flat_results: A list of tuples containing index and RecognizerResult
368
+ objects from the flattened analysis results.
369
+ :return: A RecognizerResult object selected based on the mixed strategy.
370
+ """
371
+ # Check if mixed strategy threshold is within the valid range
372
+ if not 0 <= mixed_strategy_threshold <= 1:
373
+ raise ValueError(
374
+ f"Invalid mixed strategy threshold: {mixed_strategy_threshold}."
375
+ )
376
+
377
+ score_aggregator = self._aggregate_scores(flat_results)
378
+
379
+ # Check if the highest score is greater than threshold and select accordingly
380
+ highest_score = max(
381
+ max(scores) for scores in score_aggregator.values() if scores
382
+ )
383
+ if highest_score > mixed_strategy_threshold:
384
+ return self._select_highest_confidence_entity(flat_results)
385
+ else:
386
+ return self._select_most_common_entity(flat_results)
387
+
388
+ @staticmethod
389
+ def _aggregate_scores(flat_results):
390
+ """
391
+ Aggregate the scores for each entity type from the flattened analysis results.
392
+
393
+ :param flat_results: A list of tuples containing index and RecognizerResult
394
+ objects from the flattened analysis results.
395
+ :return: A dictionary with entity types as keys and lists of scores as values.
396
+ """
397
+ score_aggregator = {}
398
+ for _, res in flat_results:
399
+ if res.entity_type not in score_aggregator:
400
+ score_aggregator[res.entity_type] = []
401
+ score_aggregator[res.entity_type].append(res.score)
402
+ return score_aggregator
403
+
404
+ @staticmethod
405
+ def _flatten_results(analyzer_results):
406
+ """
407
+ Flattens a nested lists of RecognizerResult objects into a list of tuples.
408
+
409
+ :param analyzer_results: A nested list of RecognizerResult objects from
410
+ the analysis results.
411
+ :return: A flattened list of tuples containing index and RecognizerResult
412
+ objects.
413
+ """
414
+ return [
415
+ (cell_idx, res)
416
+ for cell_idx, cell_results in enumerate(analyzer_results)
417
+ for res in cell_results
418
+ ]
@@ -0,0 +1,7 @@
1
+ """Config module for presidio-structured."""
2
+
3
+ from .structured_analysis import StructuredAnalysis
4
+
5
+ __all__ = [
6
+ "StructuredAnalysis",
7
+ ]
@@ -0,0 +1,20 @@
1
+ """Structured Analysis module."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Dict
5
+
6
+
7
+ @dataclass
8
+ class StructuredAnalysis:
9
+ """
10
+ Dataclass containing entity analysis from structured data.
11
+
12
+ Currently, this class only contains entity mapping.
13
+
14
+ param entity_mapping : dict. Mapping column/key names to entity types, e.g., {
15
+ "person.name": "PERSON",
16
+ "person.address": "LOCATION"
17
+ }
18
+ """
19
+
20
+ entity_mapping: Dict[str, str]
@@ -0,0 +1,11 @@
1
+ """Data module."""
2
+
3
+ from .data_processors import JsonDataProcessor, PandasDataProcessor
4
+ from .data_reader import CsvReader, JsonReader
5
+
6
+ __all__ = [
7
+ "CsvReader",
8
+ "JsonReader",
9
+ "PandasDataProcessor",
10
+ "JsonDataProcessor",
11
+ ]
@@ -0,0 +1,222 @@
1
+ import logging
2
+ from abc import ABC, abstractmethod
3
+ from typing import Any, Callable, Dict, List, Union
4
+
5
+ from pandas import DataFrame
6
+ from presidio_anonymizer.entities import OperatorConfig
7
+ from presidio_anonymizer.operators import OperatorsFactory, OperatorType
8
+
9
+ from presidio_structured.config import StructuredAnalysis
10
+
11
+
12
+ class DataProcessorBase(ABC):
13
+ """Abstract class to handle logic of operations over text using the operators."""
14
+
15
+ def __init__(self) -> None:
16
+ """Initialize DataProcessorBase object."""
17
+ self.logger = logging.getLogger("presidio-structured")
18
+
19
+ def operate(
20
+ self,
21
+ data: Any,
22
+ structured_analysis: StructuredAnalysis,
23
+ operators: Dict[str, OperatorConfig],
24
+ ) -> Any:
25
+ """
26
+ Perform operations over the text using the operators, as per the structured analysis.
27
+
28
+ :param data: Data to be operated on.
29
+ :param structured_analysis: Analysis schema as per the structured data.
30
+ :param operators: Dictionary containing operator configuration objects.
31
+ :return: Data after being operated upon.
32
+ """ # noqa: E501
33
+ key_to_operator_mapping = self._generate_operator_mapping(
34
+ structured_analysis, operators
35
+ )
36
+ return self._process(data, key_to_operator_mapping)
37
+
38
+ @abstractmethod
39
+ def _process(
40
+ self,
41
+ data: Union[Dict, DataFrame],
42
+ key_to_operator_mapping: Dict[str, Callable],
43
+ ) -> Union[Dict, DataFrame]:
44
+ """
45
+ Abstract method for subclasses to provide operation implementation.
46
+
47
+ :param data: Data to be operated on.
48
+ :param key_to_operator_mapping: Mapping of keys to operators.
49
+ :return: Operated data.
50
+ """
51
+ pass
52
+
53
+ @staticmethod
54
+ def _create_operator_callable(operator, params):
55
+ def operator_callable(text):
56
+ return operator.operate(params=params, text=text)
57
+
58
+ return operator_callable
59
+
60
+ def _generate_operator_mapping(
61
+ self, config, operators: Dict[str, OperatorConfig]
62
+ ) -> Dict[str, Callable]:
63
+ """
64
+ Generate a mapping of keys to operator callables.
65
+
66
+ :param config: Configuration object containing mapping of entity types to keys.
67
+ :param operators: Dictionary containing operator configuration objects.
68
+ :return: Dictionary mapping keys to operator callables.
69
+ """
70
+ key_to_operator_mapping = {}
71
+
72
+ operators_factory = OperatorsFactory()
73
+ for key, entity in config.entity_mapping.items():
74
+ self.logger.debug(f"Creating operator for key {key} and entity {entity}")
75
+ operator_config = operators.get(entity, operators.get("DEFAULT", None))
76
+ if operator_config is None:
77
+ raise ValueError(f"Operator for entity {entity} not found")
78
+ # NOTE: hardcoded OperatorType.Anonymize, as this is the only one supported.
79
+ operator = operators_factory.create_operator_class(
80
+ operator_config.operator_name, OperatorType.Anonymize
81
+ )
82
+ operator_callable = self._create_operator_callable(
83
+ operator, operator_config.params
84
+ )
85
+ key_to_operator_mapping[key] = operator_callable
86
+
87
+ return key_to_operator_mapping
88
+
89
+ def _operate_on_text(
90
+ self,
91
+ text_to_operate_on: str,
92
+ operator_callable: Callable,
93
+ ) -> str:
94
+ """
95
+ Operates on the provided text using the operator callable.
96
+
97
+ :param text_to_operate_on: Text to be operated on.
98
+ :param operator_callable: Callable that performs operation on the text.
99
+ :return: Text after operation.
100
+ """
101
+ return operator_callable(text_to_operate_on)
102
+
103
+
104
+ class PandasDataProcessor(DataProcessorBase):
105
+ """Pandas Data Processor."""
106
+
107
+ def _process(
108
+ self, data: DataFrame, key_to_operator_mapping: Dict[str, Callable]
109
+ ) -> DataFrame:
110
+ """
111
+ Operates on the given pandas DataFrame based on the provided operators.
112
+
113
+ :param data: DataFrame to be operated on.
114
+ :param key_to_operator_mapping: Mapping of keys to operator callables.
115
+ :return: DataFrame after the operation.
116
+ """
117
+
118
+ if not isinstance(data, DataFrame):
119
+ raise ValueError("Data must be a pandas DataFrame")
120
+
121
+ for key, operator_callable in key_to_operator_mapping.items():
122
+ self.logger.debug(f"Operating on column {key}")
123
+ for row in data.itertuples(index=True):
124
+ text_to_operate_on = getattr(row, key)
125
+ operated_text = self._operate_on_text(
126
+ text_to_operate_on, operator_callable
127
+ )
128
+ data.at[row.Index, key] = operated_text
129
+ return data
130
+
131
+
132
+ class JsonDataProcessor(DataProcessorBase):
133
+ """JSON Data Processor, Supports arbitrary nesting of dictionaries and lists."""
134
+
135
+ @staticmethod
136
+ def _get_nested_value(data: Union[Dict, List, None], path: List[str]) -> Any:
137
+ """
138
+ Recursively retrieves the value from nested data using a given path.
139
+
140
+ :param data: Nested data (list or dictionary).
141
+ :param path: List of keys/indexes representing the path.
142
+ :return: Retrieved value.
143
+ """
144
+ for i, key in enumerate(path):
145
+ if isinstance(data, list):
146
+ if key.isdigit():
147
+ data = data[int(key)]
148
+ else:
149
+ return [
150
+ JsonDataProcessor._get_nested_value(item, path[i:])
151
+ for item in data
152
+ ]
153
+ elif isinstance(data, dict):
154
+ data = data.get(key)
155
+ else:
156
+ return data
157
+ return data
158
+
159
+ @staticmethod
160
+ def _set_nested_value(data: Union[Dict, List], path: List[str], value: Any) -> None:
161
+ """
162
+ Recursively sets a value in nested data using a given path.
163
+
164
+ :param data: Nested data (JSON-like).
165
+ :param path: List of keys/indexes representing the path.
166
+ :param value: Value to be set.
167
+ """
168
+ for i, key in enumerate(path):
169
+ if isinstance(data, list):
170
+ if i + 1 < len(path) and path[i + 1].isdigit():
171
+ idx = int(path[i + 1])
172
+ while len(data) <= idx:
173
+ data.append({})
174
+ data = data[idx]
175
+ continue
176
+ else:
177
+ for item in data:
178
+ JsonDataProcessor._set_nested_value(item, path[i:], value)
179
+ return
180
+ elif isinstance(data, dict):
181
+ if i == len(path) - 1:
182
+ data[key] = value
183
+ else:
184
+ data = data.setdefault(key, {})
185
+
186
+ def _process(
187
+ self,
188
+ data: Union[Dict, List],
189
+ key_to_operator_mapping: Dict[str, Callable],
190
+ ) -> Union[Dict, List]:
191
+ """
192
+ Operates on the given JSON-like data based on the provided configuration.
193
+
194
+ :param data: JSON-like data to be operated on.
195
+ :param key_to_operator_mapping: maps keys to Callable operators.
196
+ :return: JSON-like data after the operation.
197
+ """
198
+
199
+ if not isinstance(data, (dict, list)):
200
+ raise ValueError("Data must be a JSON-like object")
201
+
202
+ for key, operator_callable in key_to_operator_mapping.items():
203
+ self.logger.debug(f"Operating on key {key}")
204
+ keys = key.split(".")
205
+ if isinstance(data, list):
206
+ for item in data:
207
+ self._process(item, key_to_operator_mapping)
208
+ else:
209
+ text_to_operate_on = self._get_nested_value(data, keys)
210
+ if text_to_operate_on:
211
+ if isinstance(text_to_operate_on, list):
212
+ for text in text_to_operate_on:
213
+ operated_text = self._operate_on_text(
214
+ text, operator_callable
215
+ )
216
+ self._set_nested_value(data, keys, operated_text)
217
+ else:
218
+ operated_text = self._operate_on_text(
219
+ text_to_operate_on, operator_callable
220
+ )
221
+ self._set_nested_value(data, keys, operated_text)
222
+ return data
@@ -0,0 +1,70 @@
1
+ """Helper data classes, mostly simple wrappers to ensure consistent user interface."""
2
+
3
+ import json
4
+ from abc import ABC, abstractmethod
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Union
7
+
8
+ import pandas as pd
9
+
10
+
11
+ class ReaderBase(ABC):
12
+ """
13
+ Base class for data readers.
14
+
15
+ This class should not be instantiated directly, instead init a subclass.
16
+ """
17
+
18
+ @abstractmethod
19
+ def read(self, path: Union[str, Path], **kwargs) -> Any:
20
+ """
21
+ Extract data from file located at path.
22
+
23
+ :param path: String defining the location of the file to read.
24
+ :return: The data read from the file.
25
+ """
26
+ pass
27
+
28
+
29
+ class CsvReader(ReaderBase):
30
+ """
31
+ Reader for reading csv files.
32
+
33
+ Usage::
34
+
35
+ reader = CsvReader()
36
+ data = reader.read(path="filepath.csv")
37
+
38
+ """
39
+
40
+ def read(self, path: Union[str, Path], **kwargs) -> pd.DataFrame:
41
+ """
42
+ Read csv file to pandas dataframe.
43
+
44
+ :param path: String defining the location of the csv file to read.
45
+ :return: Pandas DataFrame with the data read from the csv file.
46
+ """
47
+ return pd.read_csv(path, **kwargs)
48
+
49
+
50
+ class JsonReader(ReaderBase):
51
+ """
52
+ Reader for reading json files.
53
+
54
+ Usage::
55
+
56
+ reader = JsonReader()
57
+ data = reader.read(path="filepath.json")
58
+
59
+ """
60
+
61
+ def read(self, path: Union[str, Path], **kwargs) -> Dict[str, Any]:
62
+ """
63
+ Read json file to dict.
64
+
65
+ :param path: String defining the location of the json file to read.
66
+ :return: dictionary with the data read from the json file.
67
+ """
68
+ with open(path) as f:
69
+ data = json.load(f, **kwargs)
70
+ return data
@@ -0,0 +1 @@
1
+ # py.typed
@@ -0,0 +1,68 @@
1
+ import logging
2
+ from typing import Dict, Optional, Union
3
+
4
+ from pandas import DataFrame
5
+ from presidio_anonymizer.entities import OperatorConfig
6
+
7
+ from presidio_structured.config import StructuredAnalysis
8
+ from presidio_structured.data.data_processors import (
9
+ DataProcessorBase,
10
+ PandasDataProcessor,
11
+ )
12
+
13
+ DEFAULT = "replace"
14
+
15
+
16
+ class StructuredEngine:
17
+ """Class to implement methods for anonymizing tabular data."""
18
+
19
+ def __init__(self, data_processor: Optional[DataProcessorBase] = None) -> None:
20
+ """
21
+ Initialize the class with a data processor.
22
+
23
+ :param data_processor: Instance of DataProcessorBase.
24
+ """
25
+ if data_processor is None:
26
+ self.data_processor = PandasDataProcessor()
27
+ else:
28
+ self.data_processor = data_processor
29
+
30
+ self.logger = logging.getLogger("presidio-structured")
31
+
32
+ def anonymize(
33
+ self,
34
+ data: Union[Dict, DataFrame],
35
+ structured_analysis: StructuredAnalysis,
36
+ operators: Union[Dict[str, OperatorConfig], None] = None,
37
+ ) -> Union[Dict, DataFrame]:
38
+ """
39
+ Anonymize the given data using the given configuration.
40
+
41
+ :param data: input data as dictionary or pandas DataFrame.
42
+ :param structured_analysis: structured analysis configuration.
43
+ :param operators: a dictionary of operator configurations, optional.
44
+ :return: Anonymized dictionary or DataFrame.
45
+ """
46
+ self.logger.debug("Starting anonymization")
47
+ operators = self.__check_or_add_default_operator(operators)
48
+
49
+ return self.data_processor.operate(data, structured_analysis, operators)
50
+
51
+ def __check_or_add_default_operator(
52
+ self, operators: Union[Dict[str, OperatorConfig], None]
53
+ ) -> Dict[str, OperatorConfig]:
54
+ """
55
+ Check if the provided operators dictionary has a default operator. If not, add a default operator.
56
+
57
+ :param operators: dictionary of operator configurations.
58
+ :return: operators dictionary with the default operator added \
59
+ if it was not initially present.
60
+ """ # noqa: E501
61
+ default_operator = OperatorConfig(DEFAULT)
62
+ if not operators:
63
+ self.logger.debug("No operators provided, using default operator")
64
+ return {"DEFAULT": default_operator}
65
+ if not operators.get("DEFAULT"):
66
+ self.logger.debug("No default operator provided, using default operator")
67
+ operators["DEFAULT"] = default_operator
68
+ return operators
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ build-backend = "poetry.core.masonry.api"
3
+ requires = ["poetry-core"]
4
+
5
+ [project]
6
+ name = "presidio_structured"
7
+ version = "0.0.7"
8
+ description = "Presidio structured package - analyzes and anonymizes structured and semi-structured data."
9
+ authors = [{name = "Presidio", email = "presidio@microsoft.com"}]
10
+ license = "MIT"
11
+ classifiers = [
12
+ "License :: OSI Approved :: MIT License",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ ]
19
+ keywords = ["presidio_structured"]
20
+ urls = {Homepage = "https://github.com/data-privacy-stack/presidio"}
21
+ readme = "README.md"
22
+ requires-python = ">=3.10,<4.0"
23
+
24
+ dependencies = [
25
+ "presidio-analyzer (>=2.2.0,<3.0.0)",
26
+ "presidio-anonymizer (>=2.2.0,<3.0.0)",
27
+ "pandas (>=1.5.2,<4.0.0)",
28
+ "click (>=8.1.0,<9.0.0)",
29
+ ]
30
+
31
+ [tool.poetry.group.dev.dependencies]
32
+ pip = "*"
33
+ ruff = "*"
34
+ pytest = "*"
35
+ pytest-cov = "*"
36
+ pytest-mock = "*"
37
+ python-dotenv = "*"
38
+ pre_commit = "*"
39
+ diff-cover = "*"
40
+
41
+ [tool.coverage.run]
42
+ relative_files = true
43
+ source = ["."]