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,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Open Link Token Python Package
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
This package provides utilities for tokenizing and processing person attributes
|
|
6
|
+
using various cryptographic transformations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "2.0.0"
|
|
10
|
+
__author__ = "Open Link Token Contributors"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"__version__",
|
|
14
|
+
"__author__",
|
|
15
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Base attribute types and loader utilities."""
|
|
2
|
+
|
|
3
|
+
from .attribute import Attribute
|
|
4
|
+
from .attribute_expression import AttributeExpression
|
|
5
|
+
from .attribute_loader import AttributeLoader
|
|
6
|
+
from .base_attribute import BaseAttribute
|
|
7
|
+
from .combined_attribute import CombinedAttribute
|
|
8
|
+
from .serializable_attribute import SerializableAttribute
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Attribute",
|
|
12
|
+
"AttributeExpression",
|
|
13
|
+
"AttributeLoader",
|
|
14
|
+
"BaseAttribute",
|
|
15
|
+
"CombinedAttribute",
|
|
16
|
+
"SerializableAttribute",
|
|
17
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Attribute(ABC):
|
|
6
|
+
"""Base interface for all attributes."""
|
|
7
|
+
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def get_name(self) -> str:
|
|
10
|
+
"""Get the name of the attribute."""
|
|
11
|
+
... # pragma: no cover
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def get_aliases(self) -> List[str]:
|
|
15
|
+
"""Get the aliases for the attribute."""
|
|
16
|
+
... # pragma: no cover
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def normalize(self, value: str) -> str:
|
|
20
|
+
"""Normalize the attribute value."""
|
|
21
|
+
... # pragma: no cover
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def validate(self, value: str) -> bool:
|
|
25
|
+
"""Validate the attribute value."""
|
|
26
|
+
... # pragma: no cover
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""
|
|
3
|
+
An attribute expression determines how the value of an attribute is normalized for consumption.
|
|
4
|
+
|
|
5
|
+
Below are the components used to compose the attribute expression:
|
|
6
|
+
- `T` - trim
|
|
7
|
+
- `U` - convert to upper case
|
|
8
|
+
- `S(start_index, end_index)` - substring of value
|
|
9
|
+
- `D` - treat as date
|
|
10
|
+
- `M(regex)` - match the regular expression
|
|
11
|
+
- `R(oldString, newString)` - replace old string with new string
|
|
12
|
+
- `|` - expression separator
|
|
13
|
+
|
|
14
|
+
Examples of attribute expressions:
|
|
15
|
+
- `T|S(0,3)|U` - trim the value, then take first 3 characters, and then convert to upper case.
|
|
16
|
+
- `T|D` - trim the value, treat the value as a date in the yyyy-MM-dd format.
|
|
17
|
+
- `T|M("\\d+")` - trim the value, then make sure that the value matches the regular expression.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from typing import TYPE_CHECKING, Optional, Type
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from openlinktoken.attributes.attribute import Attribute
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AttributeExpression:
|
|
29
|
+
"""
|
|
30
|
+
An attribute expression determines how the value of an attribute is normalized for consumption.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
EXPRESSION_PATTERN = re.compile(r"\s*(?P<expr>[^ (]+)(?:\((?P<args>[^\)]+)\))?")
|
|
34
|
+
|
|
35
|
+
def __init__(self, attribute_class: Type["Attribute"], expressions: Optional[str]):
|
|
36
|
+
"""
|
|
37
|
+
Initialize the AttributeExpression.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
attribute_class: The class of the attribute being processed.
|
|
41
|
+
expressions: The string of expressions to apply.
|
|
42
|
+
"""
|
|
43
|
+
self.attribute_class = attribute_class
|
|
44
|
+
self.expressions = expressions
|
|
45
|
+
|
|
46
|
+
def get_effective_value(self, value: Optional[str]) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Get the effective value for an attribute after application of the attribute expression.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
value: The attribute value.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The effective value after applying the attribute expression.
|
|
55
|
+
"""
|
|
56
|
+
if value is None or value.strip() == "":
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
if self.expressions is None or self.expressions.strip() == "":
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
result = value
|
|
63
|
+
expression_parts = self.expressions.split("|")
|
|
64
|
+
for expression in expression_parts:
|
|
65
|
+
result = self._eval(result, expression)
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _eval_error(value: str, expression: str, inner_exception: Optional[Exception] = None) -> ValueError:
|
|
70
|
+
"""
|
|
71
|
+
Create an error for failed expression evaluation.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
value: The value being processed.
|
|
75
|
+
expression: The expression being applied.
|
|
76
|
+
inner_exception: The exception that occurred.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The error with a detailed message.
|
|
80
|
+
"""
|
|
81
|
+
message = f"Unable to evaluate expression [{expression}] over value [{value}]."
|
|
82
|
+
if inner_exception:
|
|
83
|
+
raise ValueError(message) from inner_exception
|
|
84
|
+
return ValueError(message)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def _eval(cls, value: str, expression: str) -> str:
|
|
88
|
+
"""
|
|
89
|
+
Evaluate a single expression on the given value.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
value: The value to process.
|
|
93
|
+
expression: The expression to apply.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The processed value.
|
|
97
|
+
"""
|
|
98
|
+
if value is None or expression is None:
|
|
99
|
+
raise cls._eval_error(value, expression)
|
|
100
|
+
|
|
101
|
+
matcher = cls.EXPRESSION_PATTERN.match(expression)
|
|
102
|
+
if not matcher:
|
|
103
|
+
raise cls._eval_error(value, expression)
|
|
104
|
+
|
|
105
|
+
expr = matcher.group("expr")
|
|
106
|
+
args = None
|
|
107
|
+
if matcher.group("args"):
|
|
108
|
+
args = matcher.group("args").strip().split(",")
|
|
109
|
+
|
|
110
|
+
expr_upper = expr.upper()
|
|
111
|
+
|
|
112
|
+
if expr_upper == "U":
|
|
113
|
+
return value.upper()
|
|
114
|
+
elif expr_upper == "T":
|
|
115
|
+
return value.strip()
|
|
116
|
+
elif expr_upper == "S":
|
|
117
|
+
if args is None:
|
|
118
|
+
raise cls._eval_error(value, expression)
|
|
119
|
+
return cls._substring(value, expression, args)
|
|
120
|
+
elif expr_upper == "R":
|
|
121
|
+
if args is None:
|
|
122
|
+
raise cls._eval_error(value, expression)
|
|
123
|
+
return cls._replace(value, expression, args)
|
|
124
|
+
elif expr_upper == "M":
|
|
125
|
+
if args is None:
|
|
126
|
+
raise cls._eval_error(value, expression)
|
|
127
|
+
return cls._match(value, expression, args)
|
|
128
|
+
elif expr_upper == "D":
|
|
129
|
+
return cls._date(value, expression)
|
|
130
|
+
else:
|
|
131
|
+
raise cls._eval_error(value, expression)
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def _substring(cls, value: str, expression: str, args: list[str]) -> str:
|
|
135
|
+
"""
|
|
136
|
+
Substring expression S(start, end).
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
value: The value to process.
|
|
140
|
+
expression: The expression being applied.
|
|
141
|
+
args: The arguments for the substring operation.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The extracted substring.
|
|
145
|
+
"""
|
|
146
|
+
if len(args) != 2:
|
|
147
|
+
raise cls._eval_error(value, expression)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
start = max(0, int(args[0]))
|
|
151
|
+
end = min(len(value), int(args[1]))
|
|
152
|
+
return value[start:end]
|
|
153
|
+
except (ValueError, IndexError) as ex:
|
|
154
|
+
raise cls._eval_error(value, expression, ex)
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def _replace(cls, value: str, expression: str, args: list[str]) -> str:
|
|
158
|
+
"""
|
|
159
|
+
Replace expression R(oldString, newString).
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
value: The value to process.
|
|
163
|
+
expression: The expression being applied.
|
|
164
|
+
args: The arguments for the replace operation.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The value with replacements applied.
|
|
168
|
+
"""
|
|
169
|
+
if len(args) != 2:
|
|
170
|
+
raise cls._eval_error(value, expression)
|
|
171
|
+
|
|
172
|
+
if len(args[0]) < 2 or len(args[1]) < 2:
|
|
173
|
+
raise cls._eval_error(value, expression, ValueError("Arguments must be quoted strings."))
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
# Remove quotes from the arguments
|
|
177
|
+
old_val = args[0][1:-1] if len(args[0]) >= 2 else args[0]
|
|
178
|
+
new_val = args[1][1:-1] if len(args[1]) >= 2 else args[1]
|
|
179
|
+
return value.replace(old_val, new_val)
|
|
180
|
+
except Exception as ex:
|
|
181
|
+
raise cls._eval_error(value, expression, ex)
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def _match(cls, value: str, expression: str, args: list[str]) -> str:
|
|
185
|
+
"""
|
|
186
|
+
RegEx match M(regex).
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
value: The value to process.
|
|
190
|
+
expression: The expression being applied.
|
|
191
|
+
args: The arguments for the match operation.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
The concatenated matching parts.
|
|
195
|
+
"""
|
|
196
|
+
if len(args) != 1:
|
|
197
|
+
raise cls._eval_error(value, expression)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
pattern = re.compile(args[0])
|
|
201
|
+
matches = pattern.findall(value)
|
|
202
|
+
return "".join(matches)
|
|
203
|
+
except re.error as ex:
|
|
204
|
+
raise cls._eval_error(value, expression, ex)
|
|
205
|
+
|
|
206
|
+
@classmethod
|
|
207
|
+
def _date(cls, value: str, expression: str) -> str:
|
|
208
|
+
"""
|
|
209
|
+
Date expression D.
|
|
210
|
+
|
|
211
|
+
Supported date formats, and will be changed to "yyyy-MM-dd".
|
|
212
|
+
If the date is not in the supported formats, an exception will be thrown.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
value: The value to process.
|
|
216
|
+
expression: The expression being applied.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
The formatted date string.
|
|
220
|
+
"""
|
|
221
|
+
# Supported date formats
|
|
222
|
+
possible_formats = ["%Y-%m-%d"]
|
|
223
|
+
|
|
224
|
+
for fmt in possible_formats:
|
|
225
|
+
try:
|
|
226
|
+
date = datetime.strptime(value, fmt)
|
|
227
|
+
return date.strftime("%Y-%m-%d")
|
|
228
|
+
except ValueError:
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
# If no format worked, raise an error
|
|
232
|
+
raise cls._eval_error(value, expression)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from typing import Set
|
|
4
|
+
|
|
5
|
+
from openlinktoken.attributes.attribute import Attribute
|
|
6
|
+
from openlinktoken.attributes.general.date_attribute import DateAttribute
|
|
7
|
+
from openlinktoken.attributes.general.decimal_attribute import DecimalAttribute
|
|
8
|
+
from openlinktoken.attributes.general.integer_attribute import IntegerAttribute
|
|
9
|
+
from openlinktoken.attributes.general.record_id_attribute import RecordIdAttribute
|
|
10
|
+
from openlinktoken.attributes.general.string_attribute import StringAttribute
|
|
11
|
+
from openlinktoken.attributes.general.year_attribute import YearAttribute
|
|
12
|
+
from openlinktoken.attributes.person.age_attribute import AgeAttribute
|
|
13
|
+
from openlinktoken.attributes.person.birth_date_attribute import BirthDateAttribute
|
|
14
|
+
from openlinktoken.attributes.person.birth_year_attribute import BirthYearAttribute
|
|
15
|
+
from openlinktoken.attributes.person.first_name_attribute import FirstNameAttribute
|
|
16
|
+
from openlinktoken.attributes.person.last_name_attribute import LastNameAttribute
|
|
17
|
+
from openlinktoken.attributes.person.postal_code_attribute import PostalCodeAttribute
|
|
18
|
+
from openlinktoken.attributes.person.sex_attribute import SexAttribute
|
|
19
|
+
from openlinktoken.attributes.person.social_security_number_attribute import SocialSecurityNumberAttribute
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AttributeLoader:
|
|
23
|
+
"""
|
|
24
|
+
Loads all available attribute implementations.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
raise RuntimeError("AttributeLoader should not be instantiated.")
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def load() -> Set[Attribute]:
|
|
32
|
+
"""Load all attribute implementations."""
|
|
33
|
+
return {
|
|
34
|
+
RecordIdAttribute(),
|
|
35
|
+
StringAttribute(),
|
|
36
|
+
DateAttribute(),
|
|
37
|
+
DecimalAttribute(),
|
|
38
|
+
IntegerAttribute(),
|
|
39
|
+
YearAttribute(),
|
|
40
|
+
FirstNameAttribute(),
|
|
41
|
+
LastNameAttribute(),
|
|
42
|
+
BirthDateAttribute(),
|
|
43
|
+
AgeAttribute(),
|
|
44
|
+
BirthYearAttribute(),
|
|
45
|
+
SexAttribute(),
|
|
46
|
+
SocialSecurityNumberAttribute(),
|
|
47
|
+
PostalCodeAttribute(),
|
|
48
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from openlinktoken.attributes.serializable_attribute import SerializableAttribute
|
|
4
|
+
from openlinktoken.attributes.validation.not_null_or_empty_validator import NotNullOrEmptyValidator
|
|
5
|
+
from openlinktoken.attributes.validation.serializable_attribute_validator import SerializableAttributeValidator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseAttribute(SerializableAttribute):
|
|
9
|
+
"""A base implementation of the SerializableAttribute interface.
|
|
10
|
+
|
|
11
|
+
This class provides a default implementation of the validate method
|
|
12
|
+
that validates the attribute value against a set of validation rules.
|
|
13
|
+
|
|
14
|
+
The default validation rules are:
|
|
15
|
+
- Not null or empty
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, validation_rules: List[SerializableAttributeValidator] = None):
|
|
19
|
+
if validation_rules is None:
|
|
20
|
+
validation_rules = []
|
|
21
|
+
|
|
22
|
+
rule_list = [NotNullOrEmptyValidator()]
|
|
23
|
+
rule_list.extend(validation_rules)
|
|
24
|
+
self.validation_rules = rule_list
|
|
25
|
+
|
|
26
|
+
def validate(self, value: str) -> bool:
|
|
27
|
+
"""Validates the attribute value against a set of validation rules."""
|
|
28
|
+
if value is None:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
return all(rule.eval(value) for rule in self.validation_rules)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from openlinktoken.attributes.serializable_attribute import SerializableAttribute
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CombinedAttribute(SerializableAttribute, ABC):
|
|
10
|
+
"""
|
|
11
|
+
Abstract base class for attributes that combine multiple attribute implementations.
|
|
12
|
+
|
|
13
|
+
This class allows for combining multiple attribute implementations where each
|
|
14
|
+
implementation handles a specific subset of validation and normalization logic.
|
|
15
|
+
The combined attribute will iterate through all implementations until it finds
|
|
16
|
+
one that reports the string as valid for validation, and uses the first
|
|
17
|
+
implementation that can normalize the value for normalization.
|
|
18
|
+
|
|
19
|
+
Subclasses must implement get_attribute_implementations() to provide
|
|
20
|
+
the list of attribute implementations to combine.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def get_attribute_implementations(self) -> List[SerializableAttribute]:
|
|
25
|
+
"""
|
|
26
|
+
Get the list of attribute implementations to combine.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
List[SerializableAttribute]: The list of attribute implementations
|
|
30
|
+
"""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
def validate(self, value: str) -> bool:
|
|
34
|
+
"""
|
|
35
|
+
Validate the attribute value by checking if any of the combined
|
|
36
|
+
implementations consider the value valid.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
value: The attribute value to validate
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
True if any implementation validates the value successfully, False otherwise
|
|
43
|
+
"""
|
|
44
|
+
if value is None:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
return any(impl.validate(value) for impl in self.get_attribute_implementations())
|
|
48
|
+
|
|
49
|
+
def normalize(self, value: str) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Normalize the attribute value using the first implementation that
|
|
52
|
+
successfully validates the value.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
value: The attribute value to normalize
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The normalized value from the first validating implementation,
|
|
59
|
+
or the original trimmed value if no implementation validates it
|
|
60
|
+
"""
|
|
61
|
+
if value is None:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
for impl in self.get_attribute_implementations():
|
|
65
|
+
if impl.validate(value):
|
|
66
|
+
return impl.normalize(value)
|
|
67
|
+
|
|
68
|
+
# If no implementation validates the value, return trimmed original
|
|
69
|
+
return value.strip()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""General-purpose attribute implementations."""
|
|
2
|
+
|
|
3
|
+
from .date_attribute import DateAttribute
|
|
4
|
+
from .decimal_attribute import DecimalAttribute
|
|
5
|
+
from .integer_attribute import IntegerAttribute
|
|
6
|
+
from .record_id_attribute import RecordIdAttribute
|
|
7
|
+
from .string_attribute import StringAttribute
|
|
8
|
+
from .year_attribute import YearAttribute
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DateAttribute",
|
|
12
|
+
"DecimalAttribute",
|
|
13
|
+
"IntegerAttribute",
|
|
14
|
+
"RecordIdAttribute",
|
|
15
|
+
"StringAttribute",
|
|
16
|
+
"YearAttribute",
|
|
17
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from openlinktoken.attributes.base_attribute import BaseAttribute
|
|
8
|
+
from openlinktoken.attributes.validation import RegexValidator
|
|
9
|
+
from openlinktoken.attributes.validation.serializable_attribute_validator import SerializableAttributeValidator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DateAttribute(BaseAttribute):
|
|
13
|
+
"""Represents a generic date attribute.
|
|
14
|
+
|
|
15
|
+
This class extends BaseAttribute and provides functionality for working with
|
|
16
|
+
date fields. It recognizes "Date" as a valid alias for this attribute type.
|
|
17
|
+
|
|
18
|
+
The attribute performs normalization on input values, converting them to a
|
|
19
|
+
standard format (yyyy-MM-dd).
|
|
20
|
+
|
|
21
|
+
Supported formats:
|
|
22
|
+
- yyyy-MM-dd
|
|
23
|
+
- yyyy/MM/dd
|
|
24
|
+
- MM/dd/yyyy
|
|
25
|
+
- MM-dd-yyyy
|
|
26
|
+
- dd.MM.yyyy
|
|
27
|
+
- ISO 8601 timestamps (yyyy-MM-ddTHH:mm:ss.sssZ or yyyy-MM-ddTHH:mm:ssZ)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
NAME = "Date"
|
|
31
|
+
ALIASES = [NAME]
|
|
32
|
+
|
|
33
|
+
# Regular expression pattern for validating date formats
|
|
34
|
+
# Supports: yyyy-MM-dd, yyyy/MM/dd, MM/dd/yyyy, MM-dd-yyyy, dd.MM.yyyy,
|
|
35
|
+
# and ISO 8601 timestamps
|
|
36
|
+
VALIDATION_PATTERN = re.compile(
|
|
37
|
+
r"^(?:\d{4}[-/]\d{2}[-/]\d{2}|\d{2}[-./]\d{2}[-./]\d{4}|"
|
|
38
|
+
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})?)$"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def __init__(self, additional_validators: List[SerializableAttributeValidator] = None):
|
|
42
|
+
"""
|
|
43
|
+
Initialize the DateAttribute with optional additional validators.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
additional_validators: Optional list of additional validators to apply
|
|
47
|
+
"""
|
|
48
|
+
validation_rules = [RegexValidator(self.VALIDATION_PATTERN)]
|
|
49
|
+
if additional_validators:
|
|
50
|
+
validation_rules.extend(additional_validators)
|
|
51
|
+
super().__init__(validation_rules)
|
|
52
|
+
|
|
53
|
+
def get_name(self) -> str:
|
|
54
|
+
"""Get the name of the attribute.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
str: The name "Date"
|
|
58
|
+
"""
|
|
59
|
+
return self.NAME
|
|
60
|
+
|
|
61
|
+
def get_aliases(self) -> List[str]:
|
|
62
|
+
"""Get the aliases for the attribute.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List[str]: A list containing the aliases for this attribute
|
|
66
|
+
"""
|
|
67
|
+
return self.ALIASES.copy()
|
|
68
|
+
|
|
69
|
+
def normalize(self, value: str) -> str:
|
|
70
|
+
"""Normalizes date to yyyy-MM-dd format.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
value: The date string to normalize
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
str: The normalized date in yyyy-MM-dd format
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValueError: If the date format is invalid
|
|
80
|
+
"""
|
|
81
|
+
value = value.strip()
|
|
82
|
+
|
|
83
|
+
if not value:
|
|
84
|
+
raise ValueError("Invalid date format: empty or whitespace")
|
|
85
|
+
|
|
86
|
+
# Try ISO 8601 timestamp formats first
|
|
87
|
+
iso_formats = [
|
|
88
|
+
"%Y-%m-%dT%H:%M:%S.%fZ", # yyyy-MM-ddTHH:mm:ss.sssZ
|
|
89
|
+
"%Y-%m-%dT%H:%M:%SZ", # yyyy-MM-ddTHH:mm:ssZ
|
|
90
|
+
"%Y-%m-%dT%H:%M:%S.%f%z", # yyyy-MM-ddTHH:mm:ss.sss+/-HH:MM
|
|
91
|
+
"%Y-%m-%dT%H:%M:%S%z", # yyyy-MM-ddTHH:mm:ss+/-HH:MM
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
for fmt in iso_formats:
|
|
95
|
+
try:
|
|
96
|
+
parsed_date = datetime.strptime(value, fmt)
|
|
97
|
+
return parsed_date.strftime("%Y-%m-%d")
|
|
98
|
+
except ValueError:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
# Try different date formats
|
|
102
|
+
date_formats = [
|
|
103
|
+
"%Y-%m-%d", # yyyy-MM-dd
|
|
104
|
+
"%Y/%m/%d", # yyyy/MM/dd
|
|
105
|
+
"%m/%d/%Y", # MM/dd/yyyy
|
|
106
|
+
"%m-%d-%Y", # MM-dd-yyyy
|
|
107
|
+
"%d.%m.%Y", # dd.MM.yyyy
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
for format in date_formats:
|
|
111
|
+
try:
|
|
112
|
+
parsed_date = datetime.strptime(value, format)
|
|
113
|
+
return parsed_date.strftime("%Y-%m-%d")
|
|
114
|
+
except ValueError:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
raise ValueError(f"Invalid date format: {value}")
|
|
@@ -0,0 +1,86 @@
|
|
|
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 DecimalAttribute(BaseAttribute):
|
|
11
|
+
"""Represents a generic decimal (floating-point) attribute.
|
|
12
|
+
|
|
13
|
+
This class extends BaseAttribute and provides functionality for working with
|
|
14
|
+
decimal fields. It recognizes "Decimal" 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 decimal number.
|
|
18
|
+
|
|
19
|
+
Supported formats include:
|
|
20
|
+
- Standard decimals: 123, -45.67, +89.0
|
|
21
|
+
- Decimals without leading digit: .5
|
|
22
|
+
- Decimals without trailing digits: 1.
|
|
23
|
+
- Scientific notation: 1.5e10, -3.14E-2
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
NAME = "Decimal"
|
|
27
|
+
ALIASES = [NAME]
|
|
28
|
+
|
|
29
|
+
# Regular expression pattern for validating decimal format
|
|
30
|
+
# Supports: optional sign, digits with optional decimal, scientific notation
|
|
31
|
+
VALIDATION_PATTERN = r"^\s*[+-]?(\d+\.\d*|\d*\.\d+|\d+)([eE][+-]?\d+)?\s*$"
|
|
32
|
+
|
|
33
|
+
def __init__(self, additional_validators: Optional[List[SerializableAttributeValidator]] = None):
|
|
34
|
+
"""
|
|
35
|
+
Initialize the DecimalAttribute with optional additional validators.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
additional_validators: Optional list of additional validators to apply
|
|
39
|
+
"""
|
|
40
|
+
validation_rules: List[SerializableAttributeValidator] = [RegexValidator(self.VALIDATION_PATTERN)]
|
|
41
|
+
if additional_validators:
|
|
42
|
+
validation_rules.extend(additional_validators)
|
|
43
|
+
super().__init__(validation_rules)
|
|
44
|
+
|
|
45
|
+
def get_name(self) -> str:
|
|
46
|
+
"""Get the name of the attribute.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
str: The name "Decimal"
|
|
50
|
+
"""
|
|
51
|
+
return self.NAME
|
|
52
|
+
|
|
53
|
+
def get_aliases(self) -> List[str]:
|
|
54
|
+
"""Get the aliases for the attribute.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
List[str]: A list containing the aliases for this attribute
|
|
58
|
+
"""
|
|
59
|
+
return self.ALIASES.copy()
|
|
60
|
+
|
|
61
|
+
def normalize(self, value: str) -> str:
|
|
62
|
+
"""Normalize the decimal value by trimming whitespace and parsing.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
value: The decimal string to normalize
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
str: The normalized decimal value
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: If the decimal is not valid or empty
|
|
72
|
+
"""
|
|
73
|
+
if value is None:
|
|
74
|
+
raise ValueError("Decimal value cannot be null")
|
|
75
|
+
|
|
76
|
+
value_stripped = value.strip() if value else ""
|
|
77
|
+
|
|
78
|
+
if not value_stripped:
|
|
79
|
+
raise ValueError("Invalid decimal format: empty or whitespace")
|
|
80
|
+
|
|
81
|
+
# Parse and normalize the decimal value
|
|
82
|
+
try:
|
|
83
|
+
decimal_value = float(value_stripped)
|
|
84
|
+
return str(decimal_value)
|
|
85
|
+
except ValueError:
|
|
86
|
+
raise ValueError(f"Invalid decimal format: {value}")
|