anonymization-lib 1.4.0__py3-none-any.whl

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,134 @@
1
+ from pyspark.sql import DataFrame
2
+
3
+
4
+ def transformation_pipeline(
5
+ df: DataFrame,
6
+ transformations: list = None
7
+ ) -> DataFrame:
8
+ """
9
+ Applies a sequence of anonymization transformations to a Spark DataFrame.
10
+
11
+ Each transformation is applied sequentially, where the output of one
12
+ transformation becomes the input of the next transformation.
13
+
14
+ All transformation objects must implement a callable
15
+ ``transform(df: DataFrame) -> DataFrame`` method.
16
+
17
+ This allows different anonymization techniques, such as suppression,
18
+ substitution and generalization, to be combined without coupling the
19
+ pipeline to specific transformation classes.
20
+
21
+ Parameters
22
+ ----------
23
+ df : pyspark.sql.DataFrame
24
+ Input dataset to be anonymized.
25
+
26
+ transformations : list
27
+ Ordered list of transformation objects to apply.
28
+
29
+ Each object must provide a callable ``transform`` method that
30
+ receives a Spark DataFrame and returns another Spark DataFrame.
31
+
32
+ Examples of compatible transformations include:
33
+
34
+ - ``Suppression``
35
+ - ``Substitution``
36
+ - ``Generalization``
37
+
38
+ The order of the list determines the order in which transformations
39
+ are applied.
40
+
41
+ Returns
42
+ -------
43
+ pyspark.sql.DataFrame
44
+ DataFrame resulting from applying all configured transformations
45
+ sequentially.
46
+
47
+ Raises
48
+ ------
49
+ ValueError
50
+ If the input DataFrame is None or is not Spark-compatible.
51
+
52
+ If ``transformations`` is None, is not a list, or is empty.
53
+
54
+ If any element in ``transformations`` is None or does not implement
55
+ a callable ``transform`` method.
56
+
57
+ Notes
58
+ -----
59
+ The pipeline does not trigger Spark execution by itself. Transformations
60
+ based on Spark DataFrame operations are lazy, so the resulting logical
61
+ plan is evaluated only when a Spark action is executed.
62
+
63
+ Spark's query optimizer can optimize the combined logical plan generated
64
+ by consecutive transformations.
65
+
66
+ The pipeline intentionally relies on the common ``transform`` interface
67
+ instead of checking for specific transformation classes. This allows
68
+ custom transformations to be used as long as they implement the expected
69
+ interface.
70
+
71
+ The order of transformations may affect the resulting dataset. For
72
+ example, dropping a column before another transformation attempts to use
73
+ it will cause that subsequent transformation to fail.
74
+ """
75
+
76
+ # ------------------------------------------------------
77
+ # VALIDATE INPUT DATAFRAME
78
+ # ------------------------------------------------------
79
+
80
+ if df is None:
81
+ raise ValueError(
82
+ "Input DataFrame cannot be None."
83
+ )
84
+
85
+ # Compatible with standard Spark and Spark Connect.
86
+ if not hasattr(df, "select"):
87
+ raise ValueError(
88
+ "Input must be a Spark-compatible DataFrame."
89
+ )
90
+
91
+ # ------------------------------------------------------
92
+ # VALIDATE TRANSFORMATIONS
93
+ # ------------------------------------------------------
94
+
95
+ if transformations is None:
96
+ raise ValueError(
97
+ "'transformations' must be provided and cannot be None."
98
+ )
99
+
100
+ if not isinstance(transformations, list):
101
+ raise ValueError(
102
+ "'transformations' must be a list."
103
+ )
104
+
105
+ if not transformations:
106
+ raise ValueError(
107
+ "'transformations' cannot be an empty list."
108
+ )
109
+
110
+ for index, transformation in enumerate(transformations):
111
+ if transformation is None:
112
+ raise ValueError(
113
+ f"Transformation at index {index} cannot be None."
114
+ )
115
+
116
+ if not callable(
117
+ getattr(transformation, "transform", None)
118
+ ):
119
+ raise ValueError(
120
+ f"Transformation at index {index} "
121
+ f"('{type(transformation).__name__}') must implement "
122
+ "a callable 'transform' method."
123
+ )
124
+
125
+ # ------------------------------------------------------
126
+ # APPLY TRANSFORMATIONS
127
+ # ------------------------------------------------------
128
+
129
+ final_df = df
130
+
131
+ for transformation in transformations:
132
+ final_df = transformation.transform(final_df)
133
+
134
+ return final_df
@@ -0,0 +1,343 @@
1
+ from pyspark.sql import DataFrame
2
+ from pyspark.sql import functions as F
3
+
4
+
5
+ class Substitution:
6
+ """
7
+ Applies masking to a column by replacing its values fully or partially
8
+ with a specified character.
9
+
10
+ The transformation supports two substitution modes:
11
+
12
+ - Full substitution:
13
+ Replaces every character of the original value while preserving
14
+ its original length.
15
+
16
+ - Partial substitution:
17
+ Replaces a specific portion of the original value, preserving the
18
+ characters outside the configured substitution range.
19
+
20
+ Parameters
21
+ ----------
22
+ column : str
23
+ Name of the column to be masked.
24
+
25
+ replacement_char : str, default="*"
26
+ Single character used to replace characters from the original
27
+ values.
28
+
29
+ mode : str, default="full"
30
+ Substitution mode.
31
+
32
+ Supported values are:
33
+
34
+ - ``"full"``:
35
+ Replaces the complete value.
36
+
37
+ - ``"partial"``:
38
+ Replaces only a configured portion of the value.
39
+
40
+ start : int, optional
41
+ Starting position, using a 0-based index, for partial substitution.
42
+
43
+ Required when ``mode="partial"``.
44
+
45
+ length : int, optional
46
+ Maximum number of characters to replace starting from ``start``.
47
+
48
+ Required when ``mode="partial"``.
49
+
50
+ Notes
51
+ -----
52
+ The transformation is implemented entirely using Spark SQL
53
+ expressions. No records are collected to the driver and no Python UDF
54
+ is used, allowing the operation to be executed in a distributed manner.
55
+
56
+ Null values are preserved.
57
+
58
+ Values are converted to strings before masking. Therefore, non-null
59
+ values in the transformed column are returned as strings.
60
+ """
61
+
62
+ VALID_MODES = {
63
+ "full",
64
+ "partial"
65
+ }
66
+
67
+ def __init__(
68
+ self,
69
+ column: str,
70
+ replacement_char: str = "*",
71
+ mode: str = "full",
72
+ start: int = None,
73
+ length: int = None
74
+ ):
75
+ """
76
+ Initializes the substitution transformation.
77
+
78
+ Parameters
79
+ ----------
80
+ column : str
81
+ Name of the column to be masked.
82
+
83
+ replacement_char : str, default="*"
84
+ Single character used to replace the original values.
85
+
86
+ mode : str, default="full"
87
+ Substitution mode.
88
+
89
+ Supported values are:
90
+
91
+ - ``"full"``:
92
+ Replaces the entire value.
93
+
94
+ - ``"partial"``:
95
+ Replaces only a portion of the value.
96
+
97
+ start : int, optional
98
+ Starting position, using a 0-based index, for partial
99
+ substitution.
100
+
101
+ Required when ``mode="partial"``.
102
+
103
+ length : int, optional
104
+ Number of characters to replace starting from ``start``.
105
+
106
+ Required when ``mode="partial"``.
107
+
108
+ Raises
109
+ ------
110
+ ValueError
111
+ If the column name is invalid, the replacement character does
112
+ not contain exactly one character, the mode is unsupported, or
113
+ the parameters required by the selected mode are invalid.
114
+ """
115
+ if not isinstance(column, str) or not column.strip():
116
+ raise ValueError(
117
+ "'column' must be a non-empty string."
118
+ )
119
+
120
+ if (
121
+ not isinstance(replacement_char, str)
122
+ or len(replacement_char) != 1
123
+ ):
124
+ raise ValueError(
125
+ "'replacement_char' must be a single character."
126
+ )
127
+
128
+ if mode not in self.VALID_MODES:
129
+ raise ValueError(
130
+ f"Unsupported mode '{mode}'. "
131
+ f"Use {sorted(self.VALID_MODES)}."
132
+ )
133
+
134
+ self._validate_mode_parameters(
135
+ mode,
136
+ start,
137
+ length
138
+ )
139
+
140
+ self.column = column
141
+ self.replacement_char = replacement_char
142
+ self.mode = mode
143
+ self.start = start
144
+ self.length = length
145
+
146
+ @staticmethod
147
+ def _validate_mode_parameters(
148
+ mode: str,
149
+ start: int | None,
150
+ length: int | None
151
+ ) -> None:
152
+ """
153
+ Validates parameters specific to the selected substitution mode.
154
+
155
+ Parameters
156
+ ----------
157
+ mode : str
158
+ Substitution mode. Expected values are ``"full"`` or
159
+ ``"partial"``.
160
+
161
+ start : int or None
162
+ Starting position for partial substitution.
163
+
164
+ length : int or None
165
+ Number of characters to replace in partial substitution.
166
+
167
+ Returns
168
+ -------
169
+ None
170
+ The method only validates the provided parameters.
171
+
172
+ Raises
173
+ ------
174
+ ValueError
175
+ If ``start`` or ``length`` are provided in full mode, if they
176
+ are missing in partial mode, if they are not integers, or if
177
+ their values are outside the valid range.
178
+ """
179
+ if mode == "full":
180
+ if start is not None or length is not None:
181
+ raise ValueError(
182
+ "'start' and 'length' must not be provided "
183
+ "in 'full' mode."
184
+ )
185
+
186
+ return
187
+
188
+ if start is None or length is None:
189
+ raise ValueError(
190
+ "In 'partial' mode, 'start' and 'length' "
191
+ "must be provided."
192
+ )
193
+
194
+ if (
195
+ not isinstance(start, int)
196
+ or isinstance(start, bool)
197
+ or not isinstance(length, int)
198
+ or isinstance(length, bool)
199
+ ):
200
+ raise ValueError(
201
+ "'start' and 'length' must be integers."
202
+ )
203
+
204
+ if start < 0:
205
+ raise ValueError(
206
+ "'start' must be greater than or equal to 0."
207
+ )
208
+
209
+ if length <= 0:
210
+ raise ValueError(
211
+ "'length' must be greater than 0."
212
+ )
213
+
214
+ def transform(
215
+ self,
216
+ df: DataFrame
217
+ ) -> DataFrame:
218
+ """
219
+ Applies the configured substitution to the selected column.
220
+
221
+ In full mode, every character of each non-null value is replaced
222
+ with ``replacement_char`` while preserving the original value
223
+ length.
224
+
225
+ In partial mode, substitution starts at the configured 0-based
226
+ position and replaces at most ``length`` characters. Characters
227
+ before and after the selected region are preserved.
228
+
229
+ If the requested substitution range extends beyond the end of the
230
+ value, only the available characters are replaced.
231
+
232
+ Parameters
233
+ ----------
234
+ df : pyspark.sql.DataFrame
235
+ Input dataset containing the column to be masked.
236
+
237
+ Returns
238
+ -------
239
+ pyspark.sql.DataFrame
240
+ New DataFrame with the selected column masked.
241
+
242
+ Raises
243
+ ------
244
+ ValueError
245
+ If the input DataFrame is None, is not Spark-compatible, or
246
+ does not contain the configured column.
247
+
248
+ Notes
249
+ -----
250
+ Null values are preserved.
251
+
252
+ Non-null values are converted to strings before substitution, so
253
+ the resulting transformed values are strings.
254
+
255
+ The transformation uses only native Spark SQL expressions and is
256
+ therefore evaluated in a distributed manner without collecting
257
+ dataset records to the driver.
258
+ """
259
+ if df is None:
260
+ raise ValueError(
261
+ "Input DataFrame cannot be None."
262
+ )
263
+
264
+ # Compatible with standard Spark and Spark Connect.
265
+ if not hasattr(df, "withColumn"):
266
+ raise ValueError(
267
+ "Input must be a Spark-compatible DataFrame."
268
+ )
269
+
270
+ if self.column not in df.columns:
271
+ raise ValueError(
272
+ f"The column '{self.column}' does not exist "
273
+ f"in the DataFrame."
274
+ )
275
+
276
+ source_col = F.col(self.column)
277
+ string_col = source_col.cast("string")
278
+ value_length = F.length(string_col)
279
+
280
+ # ------------------------------------------------------
281
+ # FULL SUBSTITUTION
282
+ # ------------------------------------------------------
283
+
284
+ if self.mode == "full":
285
+ masked_value = F.repeat(
286
+ F.lit(self.replacement_char),
287
+ value_length
288
+ )
289
+
290
+ return df.withColumn(
291
+ self.column,
292
+ F.when(
293
+ source_col.isNull(),
294
+ F.lit(None).cast("string")
295
+ ).otherwise(
296
+ masked_value
297
+ )
298
+ )
299
+
300
+ # ------------------------------------------------------
301
+ # PARTIAL SUBSTITUTION
302
+ # ------------------------------------------------------
303
+
304
+ replacement_length = F.greatest(
305
+ F.lit(0),
306
+ F.least(
307
+ F.lit(self.length),
308
+ value_length - F.lit(self.start)
309
+ )
310
+ )
311
+
312
+ prefix = F.substring(
313
+ string_col,
314
+ 1,
315
+ self.start
316
+ )
317
+
318
+ masked_part = F.repeat(
319
+ F.lit(self.replacement_char),
320
+ replacement_length
321
+ )
322
+
323
+ suffix = F.substring(
324
+ string_col,
325
+ self.start + self.length + 1,
326
+ value_length
327
+ )
328
+
329
+ masked_value = F.concat(
330
+ prefix,
331
+ masked_part,
332
+ suffix
333
+ )
334
+
335
+ return df.withColumn(
336
+ self.column,
337
+ F.when(
338
+ source_col.isNull(),
339
+ F.lit(None).cast("string")
340
+ ).otherwise(
341
+ masked_value
342
+ )
343
+ )