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,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()
@@ -0,0 +1,7 @@
1
+ from openlinktoken.attributes.attribute import Attribute
2
+
3
+
4
+ class SerializableAttribute(Attribute):
5
+ """Serializable attribute interface extending Attribute."""
6
+
7
+ pass
@@ -0,0 +1,7 @@
1
+ """Utility helpers for attribute processing."""
2
+
3
+ from .attribute_utilities import AttributeUtilities
4
+
5
+ __all__ = [
6
+ "AttributeUtilities",
7
+ ]