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.
Files changed (72) hide show
  1. openlinktoken/__init__.py +15 -0
  2. openlinktoken/attributes/__init__.py +17 -0
  3. openlinktoken/attributes/attribute.py +26 -0
  4. openlinktoken/attributes/attribute_expression.py +232 -0
  5. openlinktoken/attributes/attribute_loader.py +48 -0
  6. openlinktoken/attributes/base_attribute.py +31 -0
  7. openlinktoken/attributes/combined_attribute.py +69 -0
  8. openlinktoken/attributes/general/__init__.py +17 -0
  9. openlinktoken/attributes/general/date_attribute.py +117 -0
  10. openlinktoken/attributes/general/decimal_attribute.py +86 -0
  11. openlinktoken/attributes/general/integer_attribute.py +78 -0
  12. openlinktoken/attributes/general/record_id_attribute.py +41 -0
  13. openlinktoken/attributes/general/string_attribute.py +55 -0
  14. openlinktoken/attributes/general/year_attribute.py +78 -0
  15. openlinktoken/attributes/person/__init__.py +25 -0
  16. openlinktoken/attributes/person/age_attribute.py +41 -0
  17. openlinktoken/attributes/person/birth_date_attribute.py +40 -0
  18. openlinktoken/attributes/person/birth_year_attribute.py +39 -0
  19. openlinktoken/attributes/person/canadian_postal_code_attribute.py +165 -0
  20. openlinktoken/attributes/person/first_name_attribute.py +112 -0
  21. openlinktoken/attributes/person/last_name_attribute.py +146 -0
  22. openlinktoken/attributes/person/postal_code_attribute.py +42 -0
  23. openlinktoken/attributes/person/sex_attribute.py +48 -0
  24. openlinktoken/attributes/person/social_security_number_attribute.py +119 -0
  25. openlinktoken/attributes/person/us_postal_code_attribute.py +104 -0
  26. openlinktoken/attributes/serializable_attribute.py +7 -0
  27. openlinktoken/attributes/utilities/__init__.py +7 -0
  28. openlinktoken/attributes/utilities/attribute_utilities.py +224 -0
  29. openlinktoken/attributes/validation/__init__.py +23 -0
  30. openlinktoken/attributes/validation/age_range_validator.py +41 -0
  31. openlinktoken/attributes/validation/attribute_validator.py +22 -0
  32. openlinktoken/attributes/validation/date_range_validator.py +111 -0
  33. openlinktoken/attributes/validation/not_in_validator.py +48 -0
  34. openlinktoken/attributes/validation/not_null_or_empty_validator.py +21 -0
  35. openlinktoken/attributes/validation/not_starts_with_validator.py +48 -0
  36. openlinktoken/attributes/validation/regex_validator.py +44 -0
  37. openlinktoken/attributes/validation/serializable_attribute_validator.py +26 -0
  38. openlinktoken/attributes/validation/year_range_validator.py +48 -0
  39. openlinktoken/ec_key_utils.py +207 -0
  40. openlinktoken/exchange_config.py +305 -0
  41. openlinktoken/exchange_jwe.py +107 -0
  42. openlinktoken/metadata.py +104 -0
  43. openlinktoken/tokens/__init__.py +19 -0
  44. openlinktoken/tokens/base_token_definition.py +48 -0
  45. openlinktoken/tokens/definitions/t1_token.py +39 -0
  46. openlinktoken/tokens/definitions/t2_token.py +39 -0
  47. openlinktoken/tokens/definitions/t3_token.py +39 -0
  48. openlinktoken/tokens/definitions/t4_token.py +37 -0
  49. openlinktoken/tokens/definitions/t5_token.py +37 -0
  50. openlinktoken/tokens/token.py +38 -0
  51. openlinktoken/tokens/token_definition.py +46 -0
  52. openlinktoken/tokens/token_generation_exception.py +16 -0
  53. openlinktoken/tokens/token_generator.py +201 -0
  54. openlinktoken/tokens/token_generator_result.py +33 -0
  55. openlinktoken/tokens/token_registry.py +46 -0
  56. openlinktoken/tokens/tokenizer/__init__.py +11 -0
  57. openlinktoken/tokens/tokenizer/passthrough_tokenizer.py +66 -0
  58. openlinktoken/tokens/tokenizer/sha256_tokenizer.py +72 -0
  59. openlinktoken/tokens/tokenizer/tokenizer.py +31 -0
  60. openlinktoken/tokentransformer/__init__.py +43 -0
  61. openlinktoken/tokentransformer/decrypt_token_transformer.py +83 -0
  62. openlinktoken/tokentransformer/encrypt_token_transformer.py +85 -0
  63. openlinktoken/tokentransformer/encryption_constants.py +23 -0
  64. openlinktoken/tokentransformer/hash_token_transformer.py +88 -0
  65. openlinktoken/tokentransformer/jwe_match_token_formatter.py +128 -0
  66. openlinktoken/tokentransformer/match_token_constants.py +57 -0
  67. openlinktoken/tokentransformer/no_operation_token_transformer.py +24 -0
  68. openlinktoken/tokentransformer/token_transformer.py +22 -0
  69. openlinktoken-2.0.0.dist-info/METADATA +21 -0
  70. openlinktoken-2.0.0.dist-info/RECORD +72 -0
  71. openlinktoken-2.0.0.dist-info/WHEEL +5 -0
  72. openlinktoken-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,78 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ from typing import List, Optional
4
+
5
+ from openlinktoken.attributes.base_attribute import BaseAttribute
6
+ from openlinktoken.attributes.validation import RegexValidator
7
+ from openlinktoken.attributes.validation.serializable_attribute_validator import SerializableAttributeValidator
8
+
9
+
10
+ class IntegerAttribute(BaseAttribute):
11
+ """Represents a generic integer attribute.
12
+
13
+ This class extends BaseAttribute and provides functionality for working with
14
+ integer fields. It recognizes "Integer" as a valid alias for this attribute type.
15
+
16
+ The attribute performs normalization on input values by trimming whitespace
17
+ and validates that the input is a valid integer.
18
+ """
19
+
20
+ NAME = "Integer"
21
+ ALIASES = [NAME]
22
+
23
+ # Regular expression pattern for validating integer format with optional sign
24
+ VALIDATION_PATTERN = r"^\s*[+-]?\d+\s*$"
25
+
26
+ def __init__(self, additional_validators: Optional[List[SerializableAttributeValidator]] = None):
27
+ """
28
+ Initialize the IntegerAttribute with optional additional validators.
29
+
30
+ Args:
31
+ additional_validators: Optional list of additional validators to apply
32
+ """
33
+ validation_rules: List[SerializableAttributeValidator] = [RegexValidator(self.VALIDATION_PATTERN)]
34
+ if additional_validators:
35
+ validation_rules.extend(additional_validators)
36
+ super().__init__(validation_rules)
37
+
38
+ def get_name(self) -> str:
39
+ """Get the name of the attribute.
40
+
41
+ Returns:
42
+ str: The name "Integer"
43
+ """
44
+ return self.NAME
45
+
46
+ def get_aliases(self) -> List[str]:
47
+ """Get the aliases for the attribute.
48
+
49
+ Returns:
50
+ List[str]: A list containing the aliases for this attribute
51
+ """
52
+ return self.ALIASES.copy()
53
+
54
+ def normalize(self, value: str) -> str:
55
+ """Normalize the integer value by trimming whitespace and parsing.
56
+
57
+ Args:
58
+ value: The integer string to normalize
59
+
60
+ Returns:
61
+ str: The normalized integer value
62
+
63
+ Raises:
64
+ ValueError: If the integer is not valid or empty
65
+ """
66
+ if value is None:
67
+ raise ValueError("Integer value cannot be null")
68
+ if not value:
69
+ raise ValueError("Invalid integer format: empty or whitespace")
70
+
71
+ trimmed = value.strip()
72
+
73
+ # Validate it's a valid integer
74
+ try:
75
+ int_value = int(trimmed)
76
+ return str(int_value)
77
+ except ValueError:
78
+ raise ValueError(f"Invalid integer format: {value}")
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ from typing import List
4
+
5
+ from openlinktoken.attributes.general.string_attribute import StringAttribute
6
+
7
+
8
+ class RecordIdAttribute(StringAttribute):
9
+ """Represents a record identifier attribute.
10
+
11
+ This class extends StringAttribute and provides functionality for working with
12
+ record ID fields. It recognizes "RecordId" as a valid alias for this
13
+ attribute type.
14
+
15
+ The attribute normalizes values by trimming whitespace (inherited from StringAttribute).
16
+ It validates that the value is not null or empty using the default BaseAttribute
17
+ validation rules.
18
+ """
19
+
20
+ NAME = "RecordId"
21
+ ALIASES = [NAME, "Id"]
22
+
23
+ def __init__(self):
24
+ # Use validation rules from StringAttribute
25
+ super().__init__()
26
+
27
+ def get_name(self) -> str:
28
+ """Get the name of the attribute.
29
+
30
+ Returns:
31
+ str: The name "RecordId"
32
+ """
33
+ return self.NAME
34
+
35
+ def get_aliases(self) -> List[str]:
36
+ """Get the aliases for the attribute.
37
+
38
+ Returns:
39
+ List[str]: A list containing the aliases for this attribute
40
+ """
41
+ return self.ALIASES.copy()
@@ -0,0 +1,55 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ from typing import List
4
+
5
+ from openlinktoken.attributes.base_attribute import BaseAttribute
6
+
7
+
8
+ class StringAttribute(BaseAttribute):
9
+ """Represents a generic string attribute.
10
+
11
+ This class extends BaseAttribute and provides functionality for working with
12
+ string fields. It normalizes values by trimming whitespace.
13
+
14
+ The attribute validates that the value is not null or empty using the
15
+ default BaseAttribute validation rules.
16
+ """
17
+
18
+ NAME = "String"
19
+ ALIASES = [NAME, "Text"]
20
+
21
+ def __init__(self):
22
+ # Use default validation rules from BaseAttribute (not null or empty)
23
+ super().__init__()
24
+
25
+ def get_name(self) -> str:
26
+ """Get the name of the attribute.
27
+
28
+ Returns:
29
+ str: The name "String"
30
+ """
31
+ return self.NAME
32
+
33
+ def get_aliases(self) -> List[str]:
34
+ """Get the aliases for the attribute.
35
+
36
+ Returns:
37
+ List[str]: A list containing the aliases for this attribute
38
+ """
39
+ return self.ALIASES.copy()
40
+
41
+ def normalize(self, value: str) -> str:
42
+ """Normalize the string value by trimming whitespace.
43
+
44
+ Args:
45
+ value: The string value to normalize
46
+
47
+ Returns:
48
+ str: The trimmed string value
49
+
50
+ Raises:
51
+ ValueError: If value is None
52
+ """
53
+ if value is None:
54
+ raise ValueError("String value cannot be null")
55
+ return value.strip()
@@ -0,0 +1,78 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ import re
4
+ from typing import List
5
+
6
+ from openlinktoken.attributes.general.integer_attribute import IntegerAttribute
7
+ from openlinktoken.attributes.validation import RegexValidator
8
+ from openlinktoken.attributes.validation.serializable_attribute_validator import SerializableAttributeValidator
9
+
10
+
11
+ class YearAttribute(IntegerAttribute):
12
+ """Represents a generic year attribute.
13
+
14
+ This class extends IntegerAttribute and provides functionality for working with
15
+ year fields. It recognizes "Year" as a valid alias for this attribute type.
16
+
17
+ The attribute performs normalization on input values by trimming whitespace
18
+ and validates that the year is a 4-digit year format.
19
+ """
20
+
21
+ NAME = "Year"
22
+ ALIASES = [NAME]
23
+
24
+ # Regular expression pattern for validating 4-digit year format with optional whitespace
25
+ VALIDATION_PATTERN = re.compile(r"^\s*\d{4}\s*$")
26
+ # Pattern for extracting just the 4 digits
27
+ YEAR_PATTERN = re.compile(r"\d{4}")
28
+
29
+ def __init__(self, additional_validators: List[SerializableAttributeValidator] = None):
30
+ """
31
+ Initialize the YearAttribute with optional additional validators.
32
+
33
+ Args:
34
+ additional_validators: Optional list of additional validators to apply
35
+ """
36
+ validation_rules = [RegexValidator(self.VALIDATION_PATTERN)]
37
+ if additional_validators:
38
+ validation_rules.extend(additional_validators)
39
+ super().__init__(validation_rules)
40
+
41
+ def get_name(self) -> str:
42
+ """Get the name of the attribute.
43
+
44
+ Returns:
45
+ str: The name "Year"
46
+ """
47
+ return self.NAME
48
+
49
+ def get_aliases(self) -> List[str]:
50
+ """Get the aliases for the attribute.
51
+
52
+ Returns:
53
+ List[str]: A list containing the aliases for this attribute
54
+ """
55
+ return self.ALIASES.copy()
56
+
57
+ def normalize(self, value: str) -> str:
58
+ """Normalize the year value by trimming whitespace.
59
+
60
+ Args:
61
+ value: The year string to normalize
62
+
63
+ Returns:
64
+ str: The normalized year value as a string (leading zeros removed)
65
+
66
+ Raises:
67
+ ValueError: If the year is not a valid 4-digit year or empty
68
+ """
69
+ if not value:
70
+ raise ValueError(f"Invalid year format: {value}")
71
+
72
+ trimmed = value.strip()
73
+
74
+ # Validate it's exactly 4 digits using the pattern before delegating normalization
75
+ if not self.YEAR_PATTERN.match(trimmed):
76
+ raise ValueError(f"Invalid year format: {value}")
77
+
78
+ return super().normalize(trimmed)
@@ -0,0 +1,25 @@
1
+ """Person attribute implementations."""
2
+
3
+ from .age_attribute import AgeAttribute
4
+ from .birth_date_attribute import BirthDateAttribute
5
+ from .birth_year_attribute import BirthYearAttribute
6
+ from .canadian_postal_code_attribute import CanadianPostalCodeAttribute
7
+ from .first_name_attribute import FirstNameAttribute
8
+ from .last_name_attribute import LastNameAttribute
9
+ from .postal_code_attribute import PostalCodeAttribute
10
+ from .sex_attribute import SexAttribute
11
+ from .social_security_number_attribute import SocialSecurityNumberAttribute
12
+ from .us_postal_code_attribute import USPostalCodeAttribute
13
+
14
+ __all__ = [
15
+ "AgeAttribute",
16
+ "BirthDateAttribute",
17
+ "BirthYearAttribute",
18
+ "CanadianPostalCodeAttribute",
19
+ "FirstNameAttribute",
20
+ "LastNameAttribute",
21
+ "PostalCodeAttribute",
22
+ "SexAttribute",
23
+ "SocialSecurityNumberAttribute",
24
+ "USPostalCodeAttribute",
25
+ ]
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ from typing import List
4
+
5
+ from openlinktoken.attributes.general.integer_attribute import IntegerAttribute
6
+ from openlinktoken.attributes.validation.age_range_validator import AgeRangeValidator
7
+
8
+
9
+ class AgeAttribute(IntegerAttribute):
10
+ """Represents the age attribute.
11
+
12
+ This class extends IntegerAttribute and provides functionality for working with
13
+ age fields. It recognizes "Age" as a valid alias for this attribute type.
14
+
15
+ The attribute performs normalization on input values by trimming whitespace
16
+ and validates that the age is a valid integer between 0 and 120 (inherited from
17
+ IntegerAttribute for format validation).
18
+ """
19
+
20
+ NAME = "Age"
21
+ ALIASES = [NAME]
22
+
23
+ def __init__(self):
24
+ """Initialize the AgeAttribute with age range validation."""
25
+ super().__init__([AgeRangeValidator()])
26
+
27
+ def get_name(self) -> str:
28
+ """Get the name of the attribute.
29
+
30
+ Returns:
31
+ str: The name "Age"
32
+ """
33
+ return self.NAME
34
+
35
+ def get_aliases(self) -> List[str]:
36
+ """Get the aliases for the attribute.
37
+
38
+ Returns:
39
+ List[str]: A list containing the aliases for this attribute
40
+ """
41
+ return self.ALIASES.copy()
@@ -0,0 +1,40 @@
1
+ # src/openlinktoken/attributes/person/birth_date_attribute.py
2
+ from datetime import date
3
+ from typing import List
4
+
5
+ from openlinktoken.attributes.general.date_attribute import DateAttribute
6
+ from openlinktoken.attributes.validation.date_range_validator import DateRangeValidator
7
+
8
+
9
+ class BirthDateAttribute(DateAttribute):
10
+ """Represents the birth date attribute.
11
+
12
+ This class extends DateAttribute and provides functionality for working with
13
+ birth date fields. It recognizes "BirthDate" as a valid alias for this
14
+ attribute type.
15
+
16
+ The attribute performs normalization on input values, converting them to a
17
+ standard format (yyyy-MM-dd).
18
+
19
+ Supported formats:
20
+ - yyyy-MM-dd
21
+ - yyyy/MM/dd
22
+ - MM/dd/yyyy
23
+ - MM-dd-yyyy
24
+ - dd.MM.yyyy
25
+ """
26
+
27
+ NAME = "BirthDate"
28
+ ALIASES = [NAME]
29
+
30
+ def __init__(self):
31
+ # Birth dates must be between 1910-01-01 and today
32
+ min_date = date(1910, 1, 1)
33
+ validator = DateRangeValidator(min_date=min_date, use_current_as_max=True)
34
+ super().__init__(additional_validators=[validator])
35
+
36
+ def get_name(self) -> str:
37
+ return self.NAME
38
+
39
+ def get_aliases(self) -> List[str]:
40
+ return self.ALIASES.copy()
@@ -0,0 +1,39 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ from typing import List
4
+
5
+ from openlinktoken.attributes.general.year_attribute import YearAttribute
6
+ from openlinktoken.attributes.validation.year_range_validator import YearRangeValidator
7
+
8
+
9
+ class BirthYearAttribute(YearAttribute):
10
+ """Represents the birth year attribute.
11
+
12
+ This class extends YearAttribute and provides functionality for working with
13
+ birth year fields. It recognizes "BirthYear" as a valid alias for this attribute type.
14
+
15
+ The attribute performs normalization on input values by trimming whitespace
16
+ and validates that the birth year is a 4-digit year between 1910 and the current year.
17
+ """
18
+
19
+ NAME = "BirthYear"
20
+ ALIASES = [NAME, "YearOfBirth"]
21
+
22
+ def __init__(self):
23
+ super().__init__(additional_validators=[YearRangeValidator()])
24
+
25
+ def get_name(self) -> str:
26
+ """Get the name of the attribute.
27
+
28
+ Returns:
29
+ str: The name "BirthYear"
30
+ """
31
+ return self.NAME
32
+
33
+ def get_aliases(self) -> List[str]:
34
+ """Get the aliases for the attribute.
35
+
36
+ Returns:
37
+ List[str]: A list containing the aliases for this attribute
38
+ """
39
+ return self.ALIASES.copy()
@@ -0,0 +1,165 @@
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 CanadianPostalCodeAttribute(BaseAttribute):
13
+ """
14
+ Represents Canadian postal codes.
15
+
16
+ This class handles validation and normalization of Canadian postal codes,
17
+ supporting the A1A 1A1 format (letter-digit-letter space digit-letter-digit).
18
+ """
19
+
20
+ NAME = "CanadianPostalCode"
21
+ ALIASES = [NAME, "CanadianZipCode"]
22
+
23
+ # Regular expression pattern for validating Canadian postal codes
24
+ # Supports 3-character (ZIP-3), partial (4-5 char), and full 6-character formats
25
+ CANADIAN_POSTAL_REGEX = r"^\s*[A-Za-z]\d[A-Za-z](\s?\d([A-Za-z]\d?)?)?\s*$"
26
+
27
+ # Full 6-character invalid postal codes
28
+ INVALID_ZIP_CODES = {
29
+ # 6-character Canadian postal code placeholders
30
+ "A1A 1A1",
31
+ "X0X 0X0",
32
+ "Y0Y 0Y0",
33
+ "Z0Z 0Z0",
34
+ "A0A 0A0",
35
+ "B1B 1B1",
36
+ "C2C 2C2",
37
+ "K1A 0A6", # Canadian government address
38
+ "H0H 0H0", # Santa Claus postal code
39
+ }
40
+
41
+ # 3-character codes that are invalid when standalone (not when part of full codes)
42
+ INVALID_ZIP3_CODES = {
43
+ "K1A", # Canadian government
44
+ "M7A", # Government of Ontario
45
+ "H0H", # Santa Claus
46
+ }
47
+
48
+ def __init__(self, min_length: int = 6):
49
+ """
50
+ Initialize CanadianPostalCodeAttribute.
51
+
52
+ Args:
53
+ min_length: Minimum length for postal codes (default: 6)
54
+ """
55
+ validation_rules = []
56
+ super().__init__(validation_rules)
57
+ self.min_length = min_length
58
+ self.regex_validator = RegexValidator(self.CANADIAN_POSTAL_REGEX)
59
+ self.not_starts_with_validator = NotStartsWithValidator(self.INVALID_ZIP_CODES)
60
+
61
+ def validate(self, value: str) -> bool:
62
+ """
63
+ Validate the Canadian postal code value.
64
+
65
+ Args:
66
+ value: The postal code value to validate
67
+
68
+ Returns:
69
+ True if the value is a valid Canadian postal code, False otherwise
70
+ """
71
+ if value is None:
72
+ return False
73
+
74
+ # First, check the regex pattern on the ORIGINAL value
75
+ # This ensures the format is valid before normalization
76
+ if not self.regex_validator.eval(value):
77
+ return False
78
+
79
+ # Normalize the value to ensure idempotency
80
+ # This converts to uppercase and formats consistently
81
+ normalized_value = self.normalize(value)
82
+
83
+ # Validate the NORMALIZED value against the full invalid codes
84
+ # This ensures "k1a 0a6" and "K1A 0A6" are treated consistently
85
+ for invalid_code in self.INVALID_ZIP_CODES:
86
+ if normalized_value.upper() == invalid_code.upper():
87
+ return False
88
+
89
+ # Additionally check for codes starting with invalid ZIP-3 prefixes
90
+ # But ONLY for padded partial codes (normalized length is 7 and ends with padding)
91
+ # Full 6-character codes (without padding) are checked against the exact invalid list above
92
+ if len(normalized_value) == 7 and " " in normalized_value:
93
+ zip3 = normalized_value[:3].upper()
94
+ for invalid_zip3 in self.INVALID_ZIP3_CODES:
95
+ if zip3 == invalid_zip3.upper():
96
+ # Check if this is a padded partial code
97
+ # Padded codes end with "000" (3-char), "A0" (4-char), or "0" (5-char after last letter)
98
+ last_part = normalized_value[4:] # After "K1A "
99
+ if (
100
+ last_part == "000" # K1A → K1A 000
101
+ or re.match(r"\dA0$", last_part) # K1A1 → K1A 1A0
102
+ or re.match(r"\d[A-Z]0$", last_part)
103
+ ): # K1A1A → K1A 1A0
104
+ return False
105
+
106
+ return True
107
+
108
+ def get_name(self) -> str:
109
+ return self.NAME
110
+
111
+ def get_aliases(self) -> List[str]:
112
+ return self.ALIASES.copy()
113
+
114
+ def normalize(self, value: str) -> str:
115
+ """
116
+ Normalize a Canadian postal code to standard A1A 1A1 format.
117
+
118
+ For Canadian postal codes:
119
+ - Codes shorter than min_length are rejected (return original)
120
+ - 3-character format (e.g., "J1X") is padded with " 000" to create full format
121
+ (e.g., "J1X 000") if min_length <= 3
122
+ - 4-character format (e.g., "J1X1") is padded with "A0" to create full format
123
+ (e.g., "J1X 1A0") if min_length <= 4
124
+ - 5-character format (e.g., "J1X1A") is padded with "0" to create full format
125
+ (e.g., "J1X 1A0") if min_length <= 5
126
+ - 6-character format returns uppercase format with space (e.g., "k1a0a6" becomes "K1A 0A6")
127
+ If the input value is null or doesn't match Canadian postal pattern, the original
128
+ trimmed value is returned.
129
+ """
130
+ if not value:
131
+ return value
132
+
133
+ trimmed = AttributeUtilities.remove_whitespace(value.strip())
134
+
135
+ # Check if it's a 3-character Canadian postal code (ZIP-3) - pad with " 000" if min_length allows
136
+ if re.match(r"^[A-Za-z]\d[A-Za-z]$", trimmed):
137
+ if self.min_length <= 3:
138
+ upper = trimmed.upper()
139
+ return f"{upper} 000"
140
+ # If min_length > 3, reject this by returning original
141
+ return value.strip()
142
+
143
+ # Check if it's a 4-character partial postal code (e.g., "A1A1") - pad with "A0" if min_length allows
144
+ if re.match(r"^[A-Za-z]\d[A-Za-z]\d$", trimmed):
145
+ if self.min_length <= 4:
146
+ upper = trimmed.upper()
147
+ return f"{upper[:3]} {upper[3]}A0"
148
+ # If min_length > 4, reject this by returning original
149
+ return value.strip()
150
+
151
+ # Check if it's a 5-character partial postal code (e.g., "A1A1A") - pad with "0" if min_length allows
152
+ if re.match(r"^[A-Za-z]\d[A-Za-z]\d[A-Za-z]$", trimmed):
153
+ if self.min_length <= 5:
154
+ upper = trimmed.upper()
155
+ return f"{upper[:3]} {upper[3:]}0"
156
+ # If min_length > 5, reject this by returning original
157
+ return value.strip()
158
+
159
+ # Check if it's a Canadian postal code (6 alphanumeric characters)
160
+ if re.match(r"[A-Za-z]\d[A-Za-z]\d[A-Za-z]\d", trimmed):
161
+ upper = trimmed.upper()
162
+ return f"{upper[:3]} {upper[3:6]}"
163
+
164
+ # For values that don't match Canadian postal patterns, return trimmed original
165
+ return value.strip()
@@ -0,0 +1,112 @@
1
+ import re
2
+ from typing import List
3
+
4
+ from openlinktoken.attributes.base_attribute import BaseAttribute
5
+ from openlinktoken.attributes.utilities.attribute_utilities import AttributeUtilities
6
+ from openlinktoken.attributes.validation.not_in_validator import NotInValidator
7
+
8
+
9
+ class FirstNameAttribute(BaseAttribute):
10
+ """Represents the first name of a person.
11
+
12
+ This class extends BaseAttribute and provides functionality for working with
13
+ first name fields. It recognizes "FirstName" and "GivenName" as valid aliases
14
+ for this attribute type.
15
+
16
+ The attribute performs no normalization on input values, returning them
17
+ unchanged.
18
+ """
19
+
20
+ NAME = "FirstName"
21
+ ALIASES = [NAME, "GivenName"]
22
+
23
+ # Pattern to match and remove common titles
24
+ TITLE_PATTERN = re.compile(
25
+ r"(?i)^\s*(mr|mrs|ms|miss|dr|prof|capt|sir|col|gen|cmdr|lt|"
26
+ r"rabbi|father|brother|sister|hon|honorable|reverend|rev|doctor)\.?\s+",
27
+ re.IGNORECASE,
28
+ )
29
+
30
+ # Pattern to match trailing periods and middle initials in names.
31
+ #
32
+ # This pattern matches:
33
+ # - A space, followed by a single non-space character (middle initial),
34
+ # optionally followed by a period, at the end of the string.
35
+ #
36
+ # Breakdown of the regex:
37
+ # \s A space
38
+ # [^\s] Any single non-space character (middle initial)
39
+ # \.? Optional period
40
+ # $ End of string
41
+ TRAILING_PERIOD_AND_INITIAL_PATTERN = re.compile(r"\s[^\s]\.?$")
42
+
43
+ def __init__(self):
44
+ placeholder_values = AttributeUtilities.COMMON_PLACEHOLDER_NAMES
45
+ validation_rules = [NotInValidator(placeholder_values)]
46
+ super().__init__(validation_rules)
47
+
48
+ def validate(self, value: str) -> bool:
49
+ """
50
+ Validate the first name value.
51
+
52
+ Args:
53
+ value: The first name value to validate
54
+
55
+ Returns:
56
+ True if the value is a valid first name, False otherwise
57
+ """
58
+ if value is None:
59
+ return False
60
+
61
+ # First, check placeholder values on the ORIGINAL value using built-in validators
62
+ # This ensures "N/A", "<masked>", etc. are properly rejected
63
+ if not super().validate(value):
64
+ return False
65
+
66
+ # Normalize the value for validation
67
+ # This ensures that validate(normalize(x)) == validate(normalize(normalize(x)))
68
+ normalized_value = self.normalize(value)
69
+
70
+ # Check that normalized value is not empty
71
+ if normalized_value is None or not normalized_value.strip():
72
+ return False
73
+
74
+ # Check that normalized value is not a placeholder
75
+ # This ensures idempotency: values like "TEST16" normalize to "TEST" which is a placeholder
76
+ if not super().validate(normalized_value):
77
+ return False
78
+
79
+ return True
80
+
81
+ def get_name(self) -> str:
82
+ return self.NAME
83
+
84
+ def get_aliases(self) -> List[str]:
85
+ return self.ALIASES.copy()
86
+
87
+ def normalize(self, value: str) -> str:
88
+ """Returns the value unchanged after removing titles."""
89
+ if not value:
90
+ return value
91
+
92
+ normalized = AttributeUtilities.normalize_diacritics(value)
93
+
94
+ without_title = re.sub(self.TITLE_PATTERN, "", normalized).strip()
95
+
96
+ if without_title:
97
+ normalized = without_title
98
+
99
+ without_suffix = AttributeUtilities.remove_generational_suffix(normalized)
100
+
101
+ if without_suffix:
102
+ normalized = without_suffix
103
+
104
+ normalized = re.sub(self.TRAILING_PERIOD_AND_INITIAL_PATTERN, "", normalized).strip()
105
+
106
+ # Remove non-alphabetic characters
107
+ normalized = AttributeUtilities.NON_ALPHABETIC_PATTERN.sub("", normalized)
108
+
109
+ # Normalize whitespace
110
+ normalized = AttributeUtilities.WHITESPACE_PATTERN.sub(" ", normalized)
111
+
112
+ return normalized