openlinktoken 2.0.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.
- openlinktoken/__init__.py +15 -0
- openlinktoken/attributes/__init__.py +17 -0
- openlinktoken/attributes/attribute.py +26 -0
- openlinktoken/attributes/attribute_expression.py +232 -0
- openlinktoken/attributes/attribute_loader.py +48 -0
- openlinktoken/attributes/base_attribute.py +31 -0
- openlinktoken/attributes/combined_attribute.py +69 -0
- openlinktoken/attributes/general/__init__.py +17 -0
- openlinktoken/attributes/general/date_attribute.py +117 -0
- openlinktoken/attributes/general/decimal_attribute.py +86 -0
- openlinktoken/attributes/general/integer_attribute.py +78 -0
- openlinktoken/attributes/general/record_id_attribute.py +41 -0
- openlinktoken/attributes/general/string_attribute.py +55 -0
- openlinktoken/attributes/general/year_attribute.py +78 -0
- openlinktoken/attributes/person/__init__.py +25 -0
- openlinktoken/attributes/person/age_attribute.py +41 -0
- openlinktoken/attributes/person/birth_date_attribute.py +40 -0
- openlinktoken/attributes/person/birth_year_attribute.py +39 -0
- openlinktoken/attributes/person/canadian_postal_code_attribute.py +165 -0
- openlinktoken/attributes/person/first_name_attribute.py +112 -0
- openlinktoken/attributes/person/last_name_attribute.py +146 -0
- openlinktoken/attributes/person/postal_code_attribute.py +42 -0
- openlinktoken/attributes/person/sex_attribute.py +48 -0
- openlinktoken/attributes/person/social_security_number_attribute.py +119 -0
- openlinktoken/attributes/person/us_postal_code_attribute.py +104 -0
- openlinktoken/attributes/serializable_attribute.py +7 -0
- openlinktoken/attributes/utilities/__init__.py +7 -0
- openlinktoken/attributes/utilities/attribute_utilities.py +224 -0
- openlinktoken/attributes/validation/__init__.py +23 -0
- openlinktoken/attributes/validation/age_range_validator.py +41 -0
- openlinktoken/attributes/validation/attribute_validator.py +22 -0
- openlinktoken/attributes/validation/date_range_validator.py +111 -0
- openlinktoken/attributes/validation/not_in_validator.py +48 -0
- openlinktoken/attributes/validation/not_null_or_empty_validator.py +21 -0
- openlinktoken/attributes/validation/not_starts_with_validator.py +48 -0
- openlinktoken/attributes/validation/regex_validator.py +44 -0
- openlinktoken/attributes/validation/serializable_attribute_validator.py +26 -0
- openlinktoken/attributes/validation/year_range_validator.py +48 -0
- openlinktoken/ec_key_utils.py +207 -0
- openlinktoken/exchange_config.py +305 -0
- openlinktoken/exchange_jwe.py +107 -0
- openlinktoken/metadata.py +104 -0
- openlinktoken/tokens/__init__.py +19 -0
- openlinktoken/tokens/base_token_definition.py +48 -0
- openlinktoken/tokens/definitions/t1_token.py +39 -0
- openlinktoken/tokens/definitions/t2_token.py +39 -0
- openlinktoken/tokens/definitions/t3_token.py +39 -0
- openlinktoken/tokens/definitions/t4_token.py +37 -0
- openlinktoken/tokens/definitions/t5_token.py +37 -0
- openlinktoken/tokens/token.py +38 -0
- openlinktoken/tokens/token_definition.py +46 -0
- openlinktoken/tokens/token_generation_exception.py +16 -0
- openlinktoken/tokens/token_generator.py +201 -0
- openlinktoken/tokens/token_generator_result.py +33 -0
- openlinktoken/tokens/token_registry.py +46 -0
- openlinktoken/tokens/tokenizer/__init__.py +11 -0
- openlinktoken/tokens/tokenizer/passthrough_tokenizer.py +66 -0
- openlinktoken/tokens/tokenizer/sha256_tokenizer.py +72 -0
- openlinktoken/tokens/tokenizer/tokenizer.py +31 -0
- openlinktoken/tokentransformer/__init__.py +43 -0
- openlinktoken/tokentransformer/decrypt_token_transformer.py +83 -0
- openlinktoken/tokentransformer/encrypt_token_transformer.py +85 -0
- openlinktoken/tokentransformer/encryption_constants.py +23 -0
- openlinktoken/tokentransformer/hash_token_transformer.py +88 -0
- openlinktoken/tokentransformer/jwe_match_token_formatter.py +128 -0
- openlinktoken/tokentransformer/match_token_constants.py +57 -0
- openlinktoken/tokentransformer/no_operation_token_transformer.py +24 -0
- openlinktoken/tokentransformer/token_transformer.py +22 -0
- openlinktoken-2.0.0.dist-info/METADATA +21 -0
- openlinktoken-2.0.0.dist-info/RECORD +72 -0
- openlinktoken-2.0.0.dist-info/WHEEL +5 -0
- openlinktoken-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from openlinktoken.attributes.base_attribute import BaseAttribute
|
|
6
|
+
from openlinktoken.attributes.utilities.attribute_utilities import AttributeUtilities
|
|
7
|
+
from openlinktoken.attributes.validation.not_in_validator import NotInValidator
|
|
8
|
+
from openlinktoken.attributes.validation.regex_validator import RegexValidator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LastNameAttribute(BaseAttribute):
|
|
12
|
+
"""
|
|
13
|
+
Represents the last name of a person.
|
|
14
|
+
|
|
15
|
+
This class extends BaseAttribute and provides functionality for working with
|
|
16
|
+
last name fields. It recognizes "LastName" and "Surname" as valid aliases for
|
|
17
|
+
this attribute type.
|
|
18
|
+
|
|
19
|
+
The attribute normalizes values by removing diacritics, generational suffixes,
|
|
20
|
+
and non-alphabetic characters. It validates that names are either:
|
|
21
|
+
- Longer than 2 characters, or
|
|
22
|
+
- Exactly 2 characters containing at least one vowel (including names with two vowels), or
|
|
23
|
+
- The specific last name "Ng"
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
NAME = "LastName"
|
|
27
|
+
ALIASES = [NAME, "Surname"]
|
|
28
|
+
|
|
29
|
+
# Regular expression pattern for validating last names.
|
|
30
|
+
#
|
|
31
|
+
# This pattern matches:
|
|
32
|
+
# - Any name with 3 or more characters (including spaces within)
|
|
33
|
+
# - 2-character names with at least one vowel (consonant+vowel, vowel+consonant, or two vowels)
|
|
34
|
+
# - The special case "Ng" (case-insensitive)
|
|
35
|
+
# - Allows optional leading and trailing whitespace
|
|
36
|
+
#
|
|
37
|
+
# Breakdown of the regex:
|
|
38
|
+
# ^\s* Start of string, optional leading whitespace
|
|
39
|
+
# (?: Start of non-capturing group:
|
|
40
|
+
# (?:.{3,}) Any name with 3+ characters
|
|
41
|
+
# | OR
|
|
42
|
+
# (?:[^aeiouAEIOU\s][aeiouAEIOU]) 2-char: consonant + vowel (no spaces)
|
|
43
|
+
# | OR
|
|
44
|
+
# (?:[aeiouAEIOU][^aeiouAEIOU\s]) 2-char: vowel + consonant (no spaces)
|
|
45
|
+
# | OR
|
|
46
|
+
# (?:[aeiouAEIOU]{2}) 2-char: two vowels (no spaces)
|
|
47
|
+
# | OR
|
|
48
|
+
# (?:[Nn][Gg]) Special case: "Ng" (case-insensitive)
|
|
49
|
+
# )
|
|
50
|
+
# \s*$ Optional trailing whitespace, end of string
|
|
51
|
+
LAST_NAME_REGEX = (
|
|
52
|
+
r"^\s*(?:(?:.{3,})|(?:[^aeiouAEIOU\s][aeiouAEIOU])|"
|
|
53
|
+
r"(?:[aeiouAEIOU][^aeiouAEIOU\s])|(?:[aeiouAEIOU]{2})|(?:[Nn][Gg]))\s*$"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def __init__(self):
|
|
57
|
+
"""Initialize the LastNameAttribute with validation rules."""
|
|
58
|
+
validation_rules = [NotInValidator(AttributeUtilities.COMMON_PLACEHOLDER_NAMES)]
|
|
59
|
+
super().__init__(validation_rules)
|
|
60
|
+
self.regex_validator = RegexValidator(self.LAST_NAME_REGEX)
|
|
61
|
+
|
|
62
|
+
def validate(self, value: str) -> bool:
|
|
63
|
+
"""
|
|
64
|
+
Validate the last name value.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
value: The last name value to validate
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
True if the value is a valid last name, False otherwise
|
|
71
|
+
"""
|
|
72
|
+
if value is None:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
# First, check placeholder values on the ORIGINAL value using built-in validators
|
|
76
|
+
# This ensures "N/A", "<masked>", etc. are properly rejected
|
|
77
|
+
if not super().validate(value):
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
# Normalize the value for pattern matching
|
|
81
|
+
# This ensures that validate(normalize(x)) == validate(normalize(normalize(x)))
|
|
82
|
+
normalized_value = self.normalize(value)
|
|
83
|
+
|
|
84
|
+
# Reject single letters after normalization
|
|
85
|
+
if len(normalized_value) == 1:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
# Check that normalized value is not a placeholder
|
|
89
|
+
# This ensures idempotency: values like "TEST16" normalize to "TEST" which is a placeholder
|
|
90
|
+
if not super().validate(normalized_value):
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
# Validate the normalized value against the regex pattern
|
|
94
|
+
# Use the pre-created regex validator instance to avoid creating new instances on each call
|
|
95
|
+
return self.regex_validator.eval(normalized_value)
|
|
96
|
+
|
|
97
|
+
def get_name(self) -> str:
|
|
98
|
+
"""
|
|
99
|
+
Get the name of the attribute.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
The name of the attribute ("LastName")
|
|
103
|
+
"""
|
|
104
|
+
return self.NAME
|
|
105
|
+
|
|
106
|
+
def get_aliases(self) -> List[str]:
|
|
107
|
+
"""
|
|
108
|
+
Get the aliases for the attribute.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
List of aliases for the attribute (["LastName", "Surname"])
|
|
112
|
+
"""
|
|
113
|
+
return self.ALIASES.copy()
|
|
114
|
+
|
|
115
|
+
def normalize(self, value: str) -> str:
|
|
116
|
+
"""
|
|
117
|
+
Normalize the last name value.
|
|
118
|
+
|
|
119
|
+
This method performs the following normalization steps:
|
|
120
|
+
1. Remove diacritics (accents)
|
|
121
|
+
2. Remove generational suffixes (Jr, Sr, III, etc.)
|
|
122
|
+
3. Remove non-alphabetic characters (spaces, dashes, etc.)
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
value: The last name value to normalize
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The normalized last name value
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
# Step 1: Remove diacritics (é → e, ñ → n, etc.)
|
|
132
|
+
normalized_value = AttributeUtilities.normalize_diacritics(value)
|
|
133
|
+
|
|
134
|
+
# Step 2: Remove generational suffixes (Jr, Sr, III, etc.)
|
|
135
|
+
value_without_suffix = AttributeUtilities.GENERATIONAL_SUFFIX_PATTERN.sub("", normalized_value)
|
|
136
|
+
|
|
137
|
+
# If the generational suffix removal doesn't result in an empty string,
|
|
138
|
+
# continue with the value without suffix, otherwise use the value with suffix
|
|
139
|
+
# as last name
|
|
140
|
+
if value_without_suffix.strip():
|
|
141
|
+
normalized_value = value_without_suffix
|
|
142
|
+
|
|
143
|
+
# Step 4: Remove dashes, spaces and other non-alphanumeric characters
|
|
144
|
+
normalized_value = AttributeUtilities.NON_ALPHABETIC_PATTERN.sub("", normalized_value)
|
|
145
|
+
|
|
146
|
+
return normalized_value
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from openlinktoken.attributes.combined_attribute import CombinedAttribute
|
|
6
|
+
from openlinktoken.attributes.person.canadian_postal_code_attribute import CanadianPostalCodeAttribute
|
|
7
|
+
from openlinktoken.attributes.person.us_postal_code_attribute import USPostalCodeAttribute
|
|
8
|
+
from openlinktoken.attributes.serializable_attribute import SerializableAttribute
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PostalCodeAttribute(CombinedAttribute):
|
|
12
|
+
"""
|
|
13
|
+
Represents the postal code of a person.
|
|
14
|
+
|
|
15
|
+
This class combines US and Canadian postal code implementations to provide
|
|
16
|
+
functionality for working with postal code fields. It recognizes "PostalCode",
|
|
17
|
+
"ZipCode", "ZIP3", "ZIP4", and "ZIP5" as valid aliases for this attribute type.
|
|
18
|
+
|
|
19
|
+
The attribute performs normalization on input values, converting them to a
|
|
20
|
+
standard format. Supports both US ZIP codes (3, 4, or 5 digits) and Canadian
|
|
21
|
+
postal codes (3, 4, 5, or 6 characters in A1A 1A1 format). ZIP-3 codes (3 digits/characters)
|
|
22
|
+
are automatically padded to full length during normalization.
|
|
23
|
+
|
|
24
|
+
This class instantiates postal code attributes with min_length=3 to support
|
|
25
|
+
partial postal codes (ZIP-3, ZIP-4, and partial Canadian formats).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
NAME = "PostalCode"
|
|
29
|
+
ALIASES = [NAME, "ZipCode", "ZIP3", "ZIP4", "ZIP5"]
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self._implementations = [USPostalCodeAttribute(min_length=3), CanadianPostalCodeAttribute(min_length=3)]
|
|
33
|
+
super().__init__()
|
|
34
|
+
|
|
35
|
+
def get_name(self) -> str:
|
|
36
|
+
return self.NAME
|
|
37
|
+
|
|
38
|
+
def get_aliases(self) -> List[str]:
|
|
39
|
+
return self.ALIASES.copy()
|
|
40
|
+
|
|
41
|
+
def get_attribute_implementations(self) -> List[SerializableAttribute]:
|
|
42
|
+
return self._implementations
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from openlinktoken.attributes.base_attribute import BaseAttribute
|
|
6
|
+
from openlinktoken.attributes.validation.regex_validator import RegexValidator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SexAttribute(BaseAttribute):
|
|
10
|
+
"""
|
|
11
|
+
Represents an assigned sex of a person.
|
|
12
|
+
|
|
13
|
+
This class extends BaseAttribute and provides functionality for working with
|
|
14
|
+
these type of fields. It recognizes "Sex" or "Gender" as valid aliases for
|
|
15
|
+
this attribute type.
|
|
16
|
+
|
|
17
|
+
The attribute performs normalization on input values, converting them to a
|
|
18
|
+
standard format (Male or Female).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
NAME = "Sex"
|
|
22
|
+
ALIASES = [NAME, "Gender"]
|
|
23
|
+
|
|
24
|
+
# Regular expression pattern for validating sex/gender values
|
|
25
|
+
VALIDATE_REGEX = r"^\s*([Mm](ale)?|[Ff](emale)?)\s*$"
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
validation_rules = [RegexValidator(self.VALIDATE_REGEX)]
|
|
29
|
+
super().__init__(validation_rules)
|
|
30
|
+
|
|
31
|
+
def get_name(self) -> str:
|
|
32
|
+
return self.NAME
|
|
33
|
+
|
|
34
|
+
def get_aliases(self) -> List[str]:
|
|
35
|
+
return self.ALIASES.copy()
|
|
36
|
+
|
|
37
|
+
def normalize(self, value: str) -> str:
|
|
38
|
+
"""Normalize sex value to 'Male' or 'Female'."""
|
|
39
|
+
if not value:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
first_char = value.strip()[0].lower()
|
|
43
|
+
if first_char == "m":
|
|
44
|
+
return "Male"
|
|
45
|
+
elif first_char == "f":
|
|
46
|
+
return "Female"
|
|
47
|
+
else:
|
|
48
|
+
return None # Return None if can't normalize
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# src/openlinktoken/attributes/person/social_security_number_attribute.py
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import locale
|
|
5
|
+
import re
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
from openlinktoken.attributes.base_attribute import BaseAttribute
|
|
9
|
+
from openlinktoken.attributes.utilities.attribute_utilities import AttributeUtilities
|
|
10
|
+
from openlinktoken.attributes.validation.not_in_validator import NotInValidator
|
|
11
|
+
from openlinktoken.attributes.validation.regex_validator import RegexValidator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SocialSecurityNumberAttribute(BaseAttribute):
|
|
15
|
+
"""
|
|
16
|
+
Represents the social security number attribute.
|
|
17
|
+
|
|
18
|
+
This class extends BaseAttribute and provides functionality for working with
|
|
19
|
+
social security number fields. It recognizes "SocialSecurityNumber" and
|
|
20
|
+
"NationalIdentificationNumber" as valid aliases for this attribute type.
|
|
21
|
+
|
|
22
|
+
The attribute performs normalization on input values, converting them to a
|
|
23
|
+
standard format (xxx-xx-xxxx).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
NAME = "SocialSecurityNumber"
|
|
27
|
+
ALIASES = [NAME, "NationalIdentificationNumber"]
|
|
28
|
+
DASH = "-"
|
|
29
|
+
SSN_FORMAT = "{:09d}"
|
|
30
|
+
|
|
31
|
+
MIN_SSN_LENGTH = 7
|
|
32
|
+
SSN_LENGTH = 9
|
|
33
|
+
|
|
34
|
+
# Get decimal separator for current locale
|
|
35
|
+
try:
|
|
36
|
+
DECIMAL_SEPARATOR = locale.localeconv()["decimal_point"]
|
|
37
|
+
except Exception:
|
|
38
|
+
DECIMAL_SEPARATOR = "."
|
|
39
|
+
|
|
40
|
+
# Regular expression to validate Social Security Numbers
|
|
41
|
+
SSN_REGEX = (
|
|
42
|
+
rf"^(?:\d{{7,9}}(?:[{DECIMAL_SEPARATOR}]0*)?)$"
|
|
43
|
+
rf"|(?:^(?!000|666|9\d\d)(\d{{3}})-?(?!00)(\d{{2}})-?(?!0000)(\d{{4}})$)"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
DIGITS_ONLY_PATTERN = re.compile(r"\d+")
|
|
47
|
+
|
|
48
|
+
INVALID_SSNS = {
|
|
49
|
+
"111-11-1111",
|
|
50
|
+
"222-22-2222",
|
|
51
|
+
"333-33-3333",
|
|
52
|
+
"444-44-4444",
|
|
53
|
+
"555-55-5555",
|
|
54
|
+
"777-77-7777",
|
|
55
|
+
"888-88-8888",
|
|
56
|
+
"001-23-4567",
|
|
57
|
+
"010-10-1010",
|
|
58
|
+
"012-34-5678",
|
|
59
|
+
"087-65-4321",
|
|
60
|
+
"098-76-5432",
|
|
61
|
+
"099-99-9999",
|
|
62
|
+
"111-22-3333",
|
|
63
|
+
"121-21-2121",
|
|
64
|
+
"123-45-6789",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def __init__(self):
|
|
68
|
+
validation_rules = [NotInValidator(self.INVALID_SSNS), RegexValidator(self.SSN_REGEX)]
|
|
69
|
+
super().__init__(validation_rules)
|
|
70
|
+
|
|
71
|
+
def get_name(self) -> str:
|
|
72
|
+
return self.NAME
|
|
73
|
+
|
|
74
|
+
def get_aliases(self) -> List[str]:
|
|
75
|
+
return self.ALIASES.copy()
|
|
76
|
+
|
|
77
|
+
def normalize(self, original_value: str) -> str:
|
|
78
|
+
"""
|
|
79
|
+
Normalize the social security number value. Remove any dashes and format the
|
|
80
|
+
value as xxx-xx-xxxx. If not possible return the original but trimmed value.
|
|
81
|
+
"""
|
|
82
|
+
if not original_value:
|
|
83
|
+
return original_value
|
|
84
|
+
|
|
85
|
+
# Remove any whitespace
|
|
86
|
+
trimmed_value = AttributeUtilities.remove_whitespace(original_value.strip())
|
|
87
|
+
|
|
88
|
+
# Remove any dashes for now
|
|
89
|
+
normalized_value = trimmed_value.replace(self.DASH, "")
|
|
90
|
+
|
|
91
|
+
# Remove decimal point/separator and all following numbers if present
|
|
92
|
+
decimal_index = normalized_value.find(self.DECIMAL_SEPARATOR)
|
|
93
|
+
if decimal_index != -1:
|
|
94
|
+
normalized_value = normalized_value[:decimal_index]
|
|
95
|
+
|
|
96
|
+
# Check if the string contains only digits
|
|
97
|
+
if not self.DIGITS_ONLY_PATTERN.fullmatch(normalized_value):
|
|
98
|
+
return original_value # Return original if contains non-numeric characters
|
|
99
|
+
|
|
100
|
+
if len(normalized_value) < self.MIN_SSN_LENGTH or len(normalized_value) > self.SSN_LENGTH:
|
|
101
|
+
return original_value # Invalid length for SSN
|
|
102
|
+
|
|
103
|
+
normalized_value = self._pad_with_zeros(normalized_value)
|
|
104
|
+
return self._format_with_dashes(normalized_value)
|
|
105
|
+
|
|
106
|
+
def _pad_with_zeros(self, ssn: str) -> str:
|
|
107
|
+
"""Pad SSN with leading zeros if between 7-8 digits."""
|
|
108
|
+
if self.MIN_SSN_LENGTH <= len(ssn) < self.SSN_LENGTH:
|
|
109
|
+
return self.SSN_FORMAT.format(int(ssn))
|
|
110
|
+
return ssn
|
|
111
|
+
|
|
112
|
+
def _format_with_dashes(self, value: str) -> str:
|
|
113
|
+
"""Format 9-digit SSN with dashes (123-45-6789)."""
|
|
114
|
+
if len(value) == self.SSN_LENGTH:
|
|
115
|
+
area_number = value[:3]
|
|
116
|
+
group_number = value[3:5]
|
|
117
|
+
serial_number = value[5:]
|
|
118
|
+
return self.DASH.join([area_number, group_number, serial_number])
|
|
119
|
+
return value
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from openlinktoken.attributes.base_attribute import BaseAttribute
|
|
7
|
+
from openlinktoken.attributes.utilities.attribute_utilities import AttributeUtilities
|
|
8
|
+
from openlinktoken.attributes.validation.not_starts_with_validator import NotStartsWithValidator
|
|
9
|
+
from openlinktoken.attributes.validation.regex_validator import RegexValidator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class USPostalCodeAttribute(BaseAttribute):
|
|
13
|
+
"""
|
|
14
|
+
Represents US postal codes (ZIP codes).
|
|
15
|
+
|
|
16
|
+
This class handles validation and normalization of US ZIP codes, supporting
|
|
17
|
+
both 5-digit format (12345) and 5+4 format (12345-6789).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
NAME = "USPostalCode"
|
|
21
|
+
ALIASES = [NAME, "USZipCode"]
|
|
22
|
+
|
|
23
|
+
# Regular expression pattern for validating US postal (ZIP) codes
|
|
24
|
+
# Supports 3-digit (ZIP-3), 4-digit (ZIP-4), 5-digit, and 9-digit formats
|
|
25
|
+
US_ZIP_REGEX = r"^\s*(\d{3}|\d{4}|\d{5}(-\d{4})?|\d{9})\s*$"
|
|
26
|
+
|
|
27
|
+
INVALID_ZIP_CODES = {
|
|
28
|
+
# 5-digit invalid codes
|
|
29
|
+
"11111",
|
|
30
|
+
"22222",
|
|
31
|
+
"33333",
|
|
32
|
+
"66666",
|
|
33
|
+
"77777",
|
|
34
|
+
"99999",
|
|
35
|
+
# Commonly used placeholders
|
|
36
|
+
"01234",
|
|
37
|
+
"12345",
|
|
38
|
+
"54321",
|
|
39
|
+
"98765",
|
|
40
|
+
# 3-digit invalid codes (ZIP-3 prefixes that should be invalidated)
|
|
41
|
+
# Note: "000" invalidates "00000" and all codes starting with "000"
|
|
42
|
+
# Note: "555" invalidates "55555" and all codes starting with "555"
|
|
43
|
+
# Note: "888" invalidates "88888" and all codes starting with "888"
|
|
44
|
+
"000",
|
|
45
|
+
"555",
|
|
46
|
+
"888",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def __init__(self, min_length: int = 5):
|
|
50
|
+
"""
|
|
51
|
+
Initialize USPostalCodeAttribute.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
min_length: Minimum length for postal codes (default: 5)
|
|
55
|
+
"""
|
|
56
|
+
validation_rules = [RegexValidator(self.US_ZIP_REGEX), NotStartsWithValidator(self.INVALID_ZIP_CODES)]
|
|
57
|
+
super().__init__(validation_rules)
|
|
58
|
+
self.min_length = min_length
|
|
59
|
+
|
|
60
|
+
def get_name(self) -> str:
|
|
61
|
+
return self.NAME
|
|
62
|
+
|
|
63
|
+
def get_aliases(self) -> List[str]:
|
|
64
|
+
return self.ALIASES.copy()
|
|
65
|
+
|
|
66
|
+
def normalize(self, value: str) -> str:
|
|
67
|
+
"""
|
|
68
|
+
Normalize a US ZIP code to standard 5-digit format.
|
|
69
|
+
|
|
70
|
+
For US ZIP codes:
|
|
71
|
+
- Codes shorter than min_length are rejected (return original)
|
|
72
|
+
- 3-digit ZIP code (ZIP-3) is padded with "00" to create 5-digit format
|
|
73
|
+
(e.g., "951" becomes "95100") if min_length <= 3
|
|
74
|
+
- 4-digit ZIP code (ZIP-4) is padded with "0" to create 5-digit format
|
|
75
|
+
(e.g., "1234" becomes "12340") if min_length <= 4
|
|
76
|
+
- 5-digit or longer ZIP codes return the first 5 digits (e.g., "12345-6789" becomes "12345")
|
|
77
|
+
If the input value is null or doesn't match US ZIP pattern, the original
|
|
78
|
+
trimmed value is returned.
|
|
79
|
+
"""
|
|
80
|
+
if not value:
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
trimmed = AttributeUtilities.remove_whitespace(value.strip())
|
|
84
|
+
|
|
85
|
+
# Check if it's a 3-digit ZIP code (ZIP-3) - pad with "00" if min_length allows
|
|
86
|
+
if re.match(r"^\d{3}$", trimmed):
|
|
87
|
+
if self.min_length <= 3:
|
|
88
|
+
return trimmed + "00"
|
|
89
|
+
# If min_length > 3, reject this by returning original
|
|
90
|
+
return value.strip()
|
|
91
|
+
|
|
92
|
+
# Check if it's a 4-digit ZIP code (ZIP-4) - pad with "0" if min_length allows
|
|
93
|
+
if re.match(r"^\d{4}$", trimmed):
|
|
94
|
+
if self.min_length <= 4:
|
|
95
|
+
return trimmed + "0"
|
|
96
|
+
# If min_length > 4, reject this by returning original
|
|
97
|
+
return value.strip()
|
|
98
|
+
|
|
99
|
+
# Check if it's a US ZIP code (5 digits, 5+4 with dash, or 9 digits without dash)
|
|
100
|
+
if re.match(r"\d{5}(-?\d{4})?", trimmed) or re.match(r"\d{9}", trimmed):
|
|
101
|
+
return trimmed[:5]
|
|
102
|
+
|
|
103
|
+
# For values that don't match US ZIP patterns, return trimmed original
|
|
104
|
+
return value.strip()
|