labfreed 0.0.5__py3-none-any.whl → 0.2.0b1__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.

Potentially problematic release.


This version of labfreed might be problematic. Click here for more details.

Files changed (58) hide show
  1. labfreed/__init__.py +4 -1
  2. labfreed/labfreed_infrastructure.py +276 -0
  3. labfreed/pac_cat/__init__.py +17 -0
  4. labfreed/pac_cat/category_base.py +51 -0
  5. labfreed/pac_cat/pac_cat.py +159 -0
  6. labfreed/pac_cat/predefined_categories.py +190 -0
  7. labfreed/pac_id/__init__.py +19 -0
  8. labfreed/pac_id/extension.py +48 -0
  9. labfreed/pac_id/id_segment.py +90 -0
  10. labfreed/pac_id/pac_id.py +140 -0
  11. labfreed/pac_id/url_parser.py +154 -0
  12. labfreed/pac_id/url_serializer.py +80 -0
  13. labfreed/pac_id_resolver/__init__.py +2 -0
  14. labfreed/pac_id_resolver/cit_v1.py +149 -0
  15. labfreed/pac_id_resolver/cit_v2.py +303 -0
  16. labfreed/pac_id_resolver/resolver.py +81 -0
  17. labfreed/pac_id_resolver/services.py +80 -0
  18. labfreed/qr/__init__.py +1 -0
  19. labfreed/qr/generate_qr.py +422 -0
  20. labfreed/trex/__init__.py +16 -0
  21. labfreed/trex/python_convenience/__init__.py +3 -0
  22. labfreed/trex/python_convenience/data_table.py +45 -0
  23. labfreed/trex/python_convenience/pyTREX.py +242 -0
  24. labfreed/trex/python_convenience/quantity.py +46 -0
  25. labfreed/trex/table_segment.py +227 -0
  26. labfreed/trex/trex.py +69 -0
  27. labfreed/trex/trex_base_models.py +336 -0
  28. labfreed/trex/value_segments.py +111 -0
  29. labfreed/{DisplayNameExtension → utilities}/base36.py +29 -13
  30. labfreed/well_known_extensions/__init__.py +5 -0
  31. labfreed/well_known_extensions/default_extension_interpreters.py +7 -0
  32. labfreed/well_known_extensions/display_name_extension.py +40 -0
  33. labfreed/well_known_extensions/trex_extension.py +31 -0
  34. labfreed/well_known_keys/gs1/__init__.py +6 -0
  35. labfreed/well_known_keys/gs1/gs1.py +4 -0
  36. labfreed/well_known_keys/gs1/gs1_ai_enum_sorted.py +57 -0
  37. labfreed/{PAC_ID/well_known_segment_keys.py → well_known_keys/labfreed/well_known_keys.py} +1 -1
  38. labfreed/well_known_keys/unece/UneceUnits.json +33730 -0
  39. labfreed/well_known_keys/unece/__init__.py +4 -0
  40. labfreed/well_known_keys/unece/unece_units.py +68 -0
  41. labfreed-0.2.0b1.dist-info/METADATA +329 -0
  42. labfreed-0.2.0b1.dist-info/RECORD +44 -0
  43. {labfreed-0.0.5.dist-info → labfreed-0.2.0b1.dist-info}/WHEEL +1 -1
  44. labfreed/DisplayNameExtension/DisplayNameExtension.py +0 -34
  45. labfreed/PAC_CAT/__init__.py +0 -1
  46. labfreed/PAC_CAT/data_model.py +0 -109
  47. labfreed/PAC_ID/__init__.py +0 -0
  48. labfreed/PAC_ID/data_model.py +0 -215
  49. labfreed/PAC_ID/parse.py +0 -142
  50. labfreed/PAC_ID/serialize.py +0 -60
  51. labfreed/TREXExtension/data_model.py +0 -239
  52. labfreed/TREXExtension/parse.py +0 -46
  53. labfreed/TREXExtension/uncertainty.py +0 -32
  54. labfreed/TREXExtension/unit_utilities.py +0 -143
  55. labfreed/validation.py +0 -71
  56. labfreed-0.0.5.dist-info/METADATA +0 -34
  57. labfreed-0.0.5.dist-info/RECORD +0 -19
  58. {labfreed-0.0.5.dist-info → labfreed-0.2.0b1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,190 @@
1
+ ## Materials
2
+ from abc import ABC
3
+ from pydantic import Field, computed_field, model_validator
4
+
5
+ from labfreed.labfreed_infrastructure import ValidationMsgLevel
6
+ from labfreed.pac_cat.category_base import Category
7
+ from labfreed.pac_id.id_segment import IDSegment
8
+
9
+ class PredefinedCategory(Category, ABC):
10
+ '''@private
11
+ Base for Predefined catergories
12
+ '''
13
+ additional_segments: list[IDSegment] = Field(default_factory=list, exclude=True)
14
+ ''' Category segments, which are not defined in the specification'''
15
+
16
+ @computed_field
17
+ @property
18
+ def segments(self) -> list[IDSegment]:
19
+ return self._get_segments(use_short_notation=False)
20
+
21
+ def _get_segments(self, use_short_notation=False) -> list[IDSegment]:
22
+ segments = []
23
+ can_omit_keys = use_short_notation # keeps track of whether keys can still be omitted. That is the case when the segment recommendation is followed
24
+ for field_name, field_info in self.model_fields.items():
25
+ if field_name in ['key', 'additional_segments']:
26
+ continue
27
+ if value := getattr(self, field_name):
28
+ if can_omit_keys:
29
+ key = None
30
+ else:
31
+ key = field_info.alias
32
+ segments.append(IDSegment(key= key, value= value) )
33
+ else:
34
+ can_omit_keys = False
35
+ if self.additional_segments:
36
+ segments.extend(self.additional_segments)
37
+ return segments
38
+
39
+ model_config = {
40
+ "populate_by_name": True
41
+ }
42
+ ''' @private Pydantic tweak to allows model fields to be populated using their Python name, even if they have an alias defined.
43
+ The alias we need to use the GS1 code in serialization
44
+ '''
45
+
46
+
47
+
48
+ class Material_Device(PredefinedCategory):
49
+ '''Represents the -MD category'''
50
+ key: str = Field(default='-MD', frozen=True)
51
+ model_number: str|None = Field( alias='240')
52
+ serial_number: str|None = Field( alias='21')
53
+ additional_segments: list[IDSegment] = Field(default_factory=list, exclude=True)
54
+ ''' Category segments, which are not defined in the specification'''
55
+
56
+ @model_validator(mode='after')
57
+ def _validate_mandatory_fields(self):
58
+ if not self.model_number:
59
+ self._add_validation_message(
60
+ source=f"Category {self.key}",
61
+ level = ValidationMsgLevel.ERROR,
62
+ msg=f'Category key {self.key} is missing mandatory field Model Number',
63
+ highlight_pattern = f"{self.key}"
64
+ )
65
+ if not self.serial_number:
66
+ self._add_validation_message(
67
+ source=f"Category {self.key}",
68
+ level = ValidationMsgLevel.ERROR,
69
+ msg=f'Category key {self.key} is missing mandatory field Serial Number',
70
+ highlight_pattern = f"{self.key}"
71
+ )
72
+ return self
73
+
74
+ class Material_Substance(PredefinedCategory):
75
+ '''Represents the -MS category'''
76
+ key: str = Field(default='-MS', frozen=True)
77
+ product_number:str|None = Field( alias='240')
78
+ batch_number:str|None = Field(default=None, alias='10')
79
+ container_size:str|None = Field(default=None, alias='20')
80
+ container_number:str|None = Field(default=None, alias='21')
81
+ aliquot:str|None = Field(default=None, alias='250')
82
+ additional_segments: list[IDSegment] = Field(default_factory=list, exclude=True)
83
+ ''' Category segments, which are not defined in the specification'''
84
+
85
+ @model_validator(mode='after')
86
+ def _validate_mandatory_fields(self):
87
+ if not self.product_number:
88
+ self._add_validation_message(
89
+ source=f"Category {self.key}",
90
+ level = ValidationMsgLevel.ERROR,
91
+ msg=f'Category key {self.key} is missing mandatory field Product Number',
92
+ highlight_pattern = f"{self.key}"
93
+ )
94
+ return self
95
+
96
+ class Material_Consumable(PredefinedCategory):
97
+ '''Represents the -MC category'''
98
+ key: str = Field(default='-MC', frozen=True)
99
+ product_number:str|None = Field( alias='240')
100
+ batch_number:str|None = Field(default=None, alias='10')
101
+ packaging_size:str|None = Field(default=None, alias='20')
102
+ serial_number:str|None = Field(default=None, alias='21')
103
+ aliquot:str|None = Field(default=None, alias='250')
104
+ additional_segments: list[IDSegment] = Field(default_factory=list, exclude=True)
105
+ ''' Category segments, which are not defined in the specification'''
106
+
107
+ @model_validator(mode='after')
108
+ def _validate_mandatory_fields(self):
109
+ if not self.product_number:
110
+ self._add_validation_message(
111
+ source=f"Category {self.key}",
112
+ level = ValidationMsgLevel.ERROR,
113
+ msg=f"Category key {self.key} is missing mandatory field 'Product Number'",
114
+ highlight_pattern = f"{self.key}"
115
+ )
116
+ return self
117
+
118
+ class Material_Misc(Material_Consumable):
119
+ '''Represents the -MC category'''
120
+ # same fields as Consumable
121
+ key: str = Field(default='-MM', frozen=True)
122
+ product_number:str|None = Field( alias='240')
123
+ batch_number:str|None = Field(default=None, alias='10')
124
+ packaging_size:str|None = Field(default=None, alias='20')
125
+ serial_number:str|None = Field(default=None, alias='21')
126
+ aliquot:str|None = Field(default=None, alias='250')
127
+ additional_segments: list[IDSegment] = Field(default_factory=list, exclude=True)
128
+ ''' Category segments, which are not defined in the specification'''
129
+
130
+
131
+
132
+ ## Data
133
+ class Data_Abstract(PredefinedCategory, ABC):
134
+ '''@private'''
135
+ key: str
136
+ id:str|None = Field( alias='21')
137
+ additional_segments: list[IDSegment] = Field(default_factory=list, exclude=True)
138
+ ''' Category segments, which are not defined in the specification'''
139
+
140
+ @model_validator(mode='after')
141
+ def _validate_mandatory_fields(self):
142
+ if not self.id:
143
+ self._add_validation_message(
144
+ source=f"Category {self.key}",
145
+ level = ValidationMsgLevel.ERROR,
146
+ msg=f"Category key {self.key} is missing mandatory field 'ID'",
147
+ highlight_pattern = f"{self.key}"
148
+ )
149
+ return self
150
+
151
+ class Data_Result(Data_Abstract):
152
+ '''Represents the -DR category'''
153
+ key: str = Field(default='-DR', frozen=True)
154
+ id:str|None = Field( alias='21')
155
+ additional_segments: list[IDSegment] = Field(default_factory=list, exclude=True)
156
+ ''' Category segments, which are not defined in the specification'''
157
+
158
+
159
+ class Data_Method(Data_Abstract):
160
+ '''Represents the -DM category'''
161
+ key: str = Field(default='-DM', frozen=True)
162
+ id:str|None = Field( alias='21')
163
+ additional_segments: list[IDSegment] = Field(default_factory=list, exclude=True)
164
+ ''' Category segments, which are not defined in the specification'''
165
+
166
+
167
+ class Data_Calibration(Data_Abstract):
168
+ '''Represents the -DC category'''
169
+ key: str = Field(default='-DC', frozen=True)
170
+ id:str|None = Field( alias='21')
171
+ additional_segments: list[IDSegment] = Field(default_factory=list, exclude=True)
172
+ ''' Category segments, which are not defined in the specification'''
173
+
174
+
175
+ class Data_Progress(Data_Abstract):
176
+ '''Represents the -DP category'''
177
+ key: str = Field(default='-DP', frozen=True)
178
+ id:str|None = Field( alias='21')
179
+ additional_segments: list[IDSegment] = Field(default_factory=list, exclude=True)
180
+ ''' Category segments, which are not defined in the specification'''
181
+
182
+
183
+ class Data_Static(Data_Abstract):
184
+ '''Represents the -DS category'''
185
+ key: str = Field(default='-DS', frozen=True)
186
+ id:str|None = Field( alias='21')
187
+ additional_segments: list[IDSegment] = Field(default_factory=list, exclude=True)
188
+ ''' Category segments, which are not defined in the specification'''
189
+
190
+
@@ -0,0 +1,19 @@
1
+ from .pac_id import PAC_ID
2
+ from .id_segment import IDSegment
3
+ from .extension import Extension
4
+
5
+ '''@private
6
+ From a SW engineering perspective it would be best to have no dependencies from other modules to pac_id.
7
+ However from a Python users convenience perspective it is better to have one place where a pac url can be parsed and magically the extensions are in a meaningful type (e.g. TREX in TREX aware format) and categories are known of possible.
8
+
9
+ >> We have given priority to convenient usage and therefore chose to have dependencies from pac_id to pac_cat and well_known_extensions
10
+ '''
11
+
12
+
13
+ __all__ = [
14
+ "PAC_ID",
15
+ "IDSegment",
16
+ "Extension"
17
+ ]
18
+
19
+
@@ -0,0 +1,48 @@
1
+
2
+ from abc import ABC, abstractproperty
3
+
4
+ from pydantic import model_validator
5
+
6
+ from labfreed.labfreed_infrastructure import LabFREED_BaseModel
7
+
8
+
9
+ class ExtensionBase(ABC):
10
+ name: str
11
+ type: str
12
+
13
+
14
+ @abstractproperty
15
+ def data(self) -> str:
16
+ raise NotImplementedError("Subclasses must implement 'data'")
17
+
18
+
19
+
20
+
21
+ class Extension(LabFREED_BaseModel,ExtensionBase):
22
+ '''Implementation of Extension for unknown extension types'''
23
+ name:str
24
+ type:str
25
+ data_:str
26
+
27
+ @property
28
+ def data(self) -> str:
29
+ return self.data_
30
+
31
+ @staticmethod
32
+ def create(*, name, type, data):
33
+ return Extension(name=name, type=type, data=data)
34
+
35
+ @model_validator(mode='before')
36
+ @classmethod
37
+ def move_data_field(cls, values):
38
+ if "data" in values:
39
+ values["data_"] = values.pop("data")
40
+ return values
41
+
42
+ model_config = {
43
+ "extra": "allow", # Allow extra keys during pre-validation
44
+ }
45
+
46
+ def __str__(self):
47
+ return f'{self.name}${self.type}/{self.data}'
48
+
@@ -0,0 +1,90 @@
1
+ import re
2
+ from pydantic import model_validator
3
+ from labfreed.labfreed_infrastructure import LabFREED_BaseModel, ValidationMsgLevel, _quote_texts
4
+ from labfreed.well_known_keys.labfreed.well_known_keys import WellKnownKeys
5
+
6
+
7
+ _hsegment_pattern = r"[A-Za-z0-9_\-\.~!$&'()+,:;=@]|%[0-9A-Fa-f]{2}"
8
+
9
+ class IDSegment(LabFREED_BaseModel):
10
+ """ Represents an id segment of a PAC-ID. It can be a value or a key value pair.
11
+ """
12
+ key:str|None = None
13
+ ''' The key of the segment. This is optional.'''
14
+ value:str
15
+ ''' The value of the segment. (mandatory)'''
16
+
17
+ @model_validator(mode="after")
18
+ def _validate_segment(self):
19
+ key = self.key or ""
20
+ value = self.value
21
+
22
+ # MUST be a valid hsegment according to RFC 1738, but without * (see PAC-ID Extension)
23
+ # This means it must be true for both, key and value
24
+ if not_allowed_chars := set(re.sub(_hsegment_pattern, '', key)):
25
+ self._add_validation_message(
26
+ source=f"id segment key {key}",
27
+ level = ValidationMsgLevel.ERROR,
28
+ msg=f"{_quote_texts(not_allowed_chars)} must not be used. The segment key must be a valid hsegment",
29
+ highlight_pattern = key,
30
+ highlight_sub = not_allowed_chars
31
+ )
32
+
33
+ if not_allowed_chars := set(re.sub(_hsegment_pattern, '', value)):
34
+ self._add_validation_message(
35
+ source=f"id segment key {value}",
36
+ level = ValidationMsgLevel.ERROR,
37
+ msg=f"{_quote_texts(not_allowed_chars)} must not be used. The segment key must be a valid hsegment",
38
+ highlight_pattern = value,
39
+ highlight_sub = not_allowed_chars
40
+ )
41
+
42
+ # Segment key SHOULD be limited to A-Z, 0-9, and -+..
43
+ if not_recommended_chars := set(re.sub(r'[A-Z0-9-:+]', '', key)):
44
+ self._add_validation_message(
45
+ source=f"id segment key {key}",
46
+ level = ValidationMsgLevel.RECOMMENDATION,
47
+ msg=f"{_quote_texts(not_recommended_chars)} should not be used. Characters SHOULD be limited to upper case letters (A-Z), numbers (0-9), '-' and '+' ",
48
+ highlight_pattern = key,
49
+ highlight_sub = not_recommended_chars
50
+ )
51
+
52
+ # Segment key should be in Well know keys
53
+ if key and key not in [k.value for k in WellKnownKeys]:
54
+ self._add_validation_message(
55
+ source=f"id segment key {key}",
56
+ level = ValidationMsgLevel.RECOMMENDATION,
57
+ msg=f"{key} is not a well known segment key. It is RECOMMENDED to use well-known keys.",
58
+ highlight_pattern = key,
59
+ highlight_sub=[key]
60
+ )
61
+
62
+
63
+ # Segment value SHOULD be limited to A-Z, 0-9, and -+..
64
+ if not_recommended_chars := set(re.sub(r'[A-Z0-9-:+]', '', value)):
65
+ self._add_validation_message(
66
+ source=f"id segment value {value}",
67
+ level = ValidationMsgLevel.RECOMMENDATION,
68
+ msg=f"Characters {_quote_texts(not_recommended_chars)} should not be used., Characters SHOULD be limited to upper case letters (A-Z), numbers (0-9), '-' and '+' ",
69
+ highlight_pattern = value,
70
+ highlight_sub = not_recommended_chars
71
+ )
72
+
73
+ # Segment value SHOULD be limited to A-Z, 0-9, and :-+ for new designs.
74
+ # this means that ":" in key or value is problematic
75
+ if ':' in key:
76
+ self._add_validation_message(
77
+ source=f"id segment key {key}",
78
+ level = ValidationMsgLevel.RECOMMENDATION,
79
+ msg="Character ':' should not be used in segment key, since this character is used to separate key and value this can lead to undefined behaviour.",
80
+ highlight_pattern = key
81
+ )
82
+ if ':' in value:
83
+ self._add_validation_message(
84
+ source=f"id segment value {value}",
85
+ level = ValidationMsgLevel.RECOMMENDATION,
86
+ msg="Character ':' should not be used in segment value, since this character is used to separate key and value this can lead to undefined behaviour.",
87
+ highlight_pattern = value
88
+ )
89
+
90
+ return self
@@ -0,0 +1,140 @@
1
+ import re
2
+ from typing_extensions import Self
3
+ from pydantic import Field, conlist, model_validator
4
+
5
+ from labfreed.labfreed_infrastructure import LabFREED_BaseModel, ValidationMsgLevel
6
+ from labfreed.pac_id.id_segment import IDSegment
7
+ from labfreed.pac_id.extension import Extension
8
+
9
+
10
+ from typing import TYPE_CHECKING
11
+ if TYPE_CHECKING:
12
+ pass
13
+
14
+
15
+ _domain_name_pattern = r"(?!-)([A-Za-z0-9-]{1,63}(?<!-)\.)+[A-Za-z]{2,63}"
16
+
17
+ class PAC_ID(LabFREED_BaseModel):
18
+ '''Represents a PAC-ID.
19
+ Refer to the [specification](https://github.com/ApiniLabs/PAC-ID?tab=readme-ov-file#specification) for details.
20
+ '''
21
+ issuer:str
22
+ '''The issuer of the PAC-ID.'''
23
+ identifier: conlist(IDSegment) = Field(..., default_factory=list) # type: ignore # exclude=True prevents this from being serialized by Pydantic
24
+ '''The identifier of the PAC-ID is a series of IDSegments'''
25
+
26
+ extensions: list[Extension] = Field(default_factory=list)
27
+
28
+
29
+ def get_extension_of_type(self, type:str) -> list[Extension]:
30
+ '''Get all extensions of a certain type.'''
31
+ return [e for e in self.extensions if e.type == type]
32
+
33
+
34
+ def get_extension(self, name:str) -> Extension|None:
35
+ '''Get extension of certain name'''
36
+ out = [e for e in self.extensions if e.name == name]
37
+ if not out:
38
+ return None
39
+ return out[0]
40
+
41
+ @classmethod
42
+ def from_url(cls, url, *, extension_interpreters='default',
43
+ try_pac_cat=True,
44
+ suppress_validation_errors=False) -> Self:
45
+ from labfreed.pac_id.url_parser import PAC_Parser
46
+ return PAC_Parser.from_url(url, try_pac_cat=try_pac_cat, suppress_validation_errors=suppress_validation_errors, extension_interpreters=extension_interpreters)
47
+
48
+ def to_url(self, use_short_notation=False, uppercase_only=False) -> str:
49
+ from labfreed.pac_id.url_serializer import PACID_Serializer
50
+ return PACID_Serializer.to_url(self, use_short_notation=use_short_notation, uppercase_only=uppercase_only)
51
+
52
+ def to_json(self, indent=None) -> str:
53
+ if not indent:
54
+ return self.model_dump_json()
55
+ else:
56
+ return self.model_dump_json(indent=indent)
57
+
58
+ def to_dict(self) -> dict:
59
+ return self.model_dump()
60
+
61
+ def __str__(self):
62
+ return self.to_url()
63
+
64
+
65
+ @model_validator(mode='after')
66
+ def _check_at_least_one_segment(self) -> Self:
67
+ if not len(self.identifier) >= 1:
68
+ self._add_validation_message(
69
+ source="identifier",
70
+ level = ValidationMsgLevel.ERROR,
71
+ msg='Identifier must contain et least one segment.'
72
+ )
73
+ return self
74
+
75
+
76
+ @model_validator(mode='after')
77
+ def _check_length(self) -> Self:
78
+ length = 0
79
+ for s in self.identifier:
80
+ s:IDSegment = s
81
+ if s.key:
82
+ length += len(s.key)
83
+ length += 1 # for ":"
84
+ length += len(s.value)
85
+ length += len(self.identifier) - 1 # account for "/" separating the segments
86
+
87
+ if length > 256:
88
+ self._add_validation_message(
89
+ source="identifier",
90
+ level = ValidationMsgLevel.ERROR,
91
+ msg=f'Identifier is {length} characters long, Identifier must not exceed 256 characters.'
92
+ )
93
+ return self
94
+
95
+
96
+ @model_validator(mode="after")
97
+ def _validate_issuer(self):
98
+ if not re.fullmatch(_domain_name_pattern, self.issuer):
99
+ self._add_validation_message(
100
+ source="PAC-ID",
101
+ level = ValidationMsgLevel.ERROR,
102
+ highlight_pattern=self.issuer,
103
+ msg="Issuer must be a valid domain name. "
104
+ )
105
+
106
+ # recommendation that A-Z, 0-9, -, and . should be used
107
+ if not_recommended_chars := set(re.sub(r'[A-Z0-9\.-]', '', self.issuer)):
108
+ self._add_validation_message(
109
+ source="PAC-ID",
110
+ level = ValidationMsgLevel.RECOMMENDATION,
111
+ highlight_pattern=self.issuer,
112
+ highlight_sub=not_recommended_chars,
113
+ msg=f"Characters {' '.join(not_recommended_chars)} should not be used. Issuer SHOULD contain only the characters A-Z, 0-9, -, and . "
114
+ )
115
+ return self
116
+
117
+
118
+ @model_validator(mode='after')
119
+ def _check_identifier_segment_keys_are_unique(self) -> Self:
120
+ keys = [s.key for s in self.identifier if s.key]
121
+ duplicate_keys = [k for k in set(keys) if keys.count(k) > 1]
122
+ if duplicate_keys:
123
+ for k in duplicate_keys:
124
+ self._add_validation_message(
125
+ source=f"identifier {k}",
126
+ level = ValidationMsgLevel.RECOMMENDATION,
127
+ msg=f"Duplicate segment key {k}. This will probably lead to undefined behaviour",
128
+ highlight_pattern = k
129
+ )
130
+ return self
131
+
132
+
133
+
134
+
135
+
136
+
137
+
138
+
139
+
140
+
@@ -0,0 +1,154 @@
1
+
2
+
3
+ import re
4
+ from types import MappingProxyType
5
+
6
+ from labfreed.labfreed_infrastructure import LabFREED_ValidationError
7
+
8
+ from labfreed.pac_id.id_segment import IDSegment
9
+ from labfreed.pac_id.extension import Extension
10
+
11
+ from labfreed.pac_cat import PAC_CAT
12
+ from labfreed.well_known_extensions import default_extension_interpreters
13
+
14
+
15
+ from typing import TYPE_CHECKING
16
+ if TYPE_CHECKING:
17
+ # only imported during type checking
18
+ from labfreed.pac_id import PAC_ID
19
+ from labfreed.pac_cat import PAC_CAT
20
+
21
+ class PAC_Parser():
22
+ '''@private
23
+ Knows how to parse a PAC-ID.
24
+ From a SW engineering perspective it would be best to have no dependencies from other modules to pac_id.
25
+ However from a Python users convenience perspective it is better to have one place where a pac url can be parsed and magically the extensions are in a meaningful type (e.g. TREX in TREX aware format) and categories are known of possible.
26
+
27
+ >> We have given priority to convenient usage and therefore chose to have dependencies from pac_id to pac_cat and well_known_extensions
28
+ '''
29
+
30
+ @classmethod
31
+ def from_url(cls, pac_url:str,
32
+ *,
33
+ extension_interpreters = 'default',
34
+ try_pac_cat = True,
35
+ suppress_validation_errors=False
36
+ ) -> "PAC_ID":
37
+ """Parses a PAC-ID with extensions
38
+
39
+ Args:
40
+ pac_url (str): pac id with optional extensions: e.g. HTTPS://PAC.METTORIUS.COM/-MD/BAL500/1234*N$N/ABC*SUM$TREX/A$T.A:ABC
41
+
42
+ Raises:
43
+ LabFREED_ValidationError: When validation fails. Note,that with suppress_errors no such error is raises
44
+
45
+ Returns:
46
+ PACID including extensions. If possible PAC-CAT is applied and extensions are cast to a known type, which knows how to inetrprete the data
47
+ """
48
+ if extension_interpreters == 'default':
49
+ extension_interpreters = default_extension_interpreters
50
+
51
+ if '*' in pac_url:
52
+ id_str, ext_str = pac_url.split('*', 1)
53
+ else:
54
+ id_str = pac_url
55
+ ext_str = ""
56
+
57
+ pac_id = cls._parse_pac_id(id_str)
58
+
59
+ # try converting to PAC-CAT. This can fail, in which case a regular PAC-ID is returned
60
+ if try_pac_cat:
61
+ try:
62
+ pac_cat = PAC_CAT.from_pac_id(pac_id)
63
+ if pac_cat.categories:
64
+ pac_id = pac_cat
65
+ except LabFREED_ValidationError:
66
+ pass
67
+
68
+ extensions = cls._parse_extensions(ext_str)
69
+ if extensions and extension_interpreters:
70
+ for i, e in enumerate(extensions):
71
+ if interpreter := extension_interpreters.get(e.type):
72
+ extensions[i] = interpreter.from_extension(e)
73
+ pac_id.extensions = extensions
74
+
75
+ if not pac_id.is_valid and not suppress_validation_errors:
76
+ raise LabFREED_ValidationError(validation_msgs = pac_id._get_nested_validation_messages())
77
+
78
+ return pac_id
79
+
80
+ @classmethod
81
+ def _parse_pac_id(cls,id_str:str) -> "PAC_ID":
82
+ # m = re.match('(HTTPS://)?(PAC.)?(?P<issuer>.+?\..+?)/(?P<identifier>.*)', id_str)
83
+ m = re.match('(HTTPS://)?(PAC.)?(?P<issuer>.+?)/(?P<identifier>.*)', id_str)
84
+ d = m.groupdict()
85
+
86
+ id_segments = list()
87
+ id_segments = cls._parse_id_segments(d.get('identifier'))
88
+
89
+ from labfreed.pac_id import PAC_ID
90
+ pac = PAC_ID(issuer= d.get('issuer'),
91
+ identifier=id_segments
92
+ )
93
+
94
+ return pac
95
+
96
+ @classmethod
97
+ def _parse_id_segments(cls, identifier:str):
98
+ if not identifier:
99
+ return []
100
+
101
+ id_segments = list()
102
+ if len(identifier) > 0 and identifier[0] == '/':
103
+ identifier = identifier[1:]
104
+ for s in identifier.split('/'):
105
+ tmp = s.split(':')
106
+
107
+ if len(tmp) == 1:
108
+ segment = IDSegment(value=tmp[0])
109
+ elif len(tmp) == 2:
110
+ segment = IDSegment(key=tmp[0], value=tmp[1])
111
+ else:
112
+ raise ValueError(f'invalid segment: {s}')
113
+
114
+ id_segments.append(segment)
115
+ return id_segments
116
+
117
+
118
+ @classmethod
119
+ def _parse_extensions(cls, extensions_str:str|None) -> list["Extension"]:
120
+
121
+ extensions = list()
122
+
123
+ if not extensions_str:
124
+ return extensions
125
+
126
+ defaults = MappingProxyType(
127
+ {
128
+ 0: { 'name': 'N', 'type': 'N'},
129
+ 1: { 'name': 'SUM', 'type': 'TREX'}
130
+ }
131
+ )
132
+ for i, e in enumerate(extensions_str.split('*')):
133
+ if e == '': #this will happen if first extension starts with *
134
+ continue
135
+ d = re.match('((?P<name>.+)\$(?P<type>.+)/)?(?P<data>.+)', e).groupdict()
136
+
137
+ name = d.get('name')
138
+ type = d.get('type')
139
+ data = d.get('data')
140
+
141
+ if name:
142
+ defaults = None # once a name was specified no longer assign defaults
143
+ else:
144
+ if defaults:
145
+ name = defaults.get(i).get('name')
146
+ type = defaults.get(i).get('type')
147
+ else:
148
+ raise ValueError('extension number {i}, must have name and type')
149
+
150
+ #convert to subtype if they were given
151
+ e = Extension.create(name=name, type=type, data=data)
152
+ extensions.append(e)
153
+
154
+ return extensions