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
labfreed/__init__.py CHANGED
@@ -2,4 +2,7 @@
2
2
  Python implementation of LabFREED building blocks
3
3
  '''
4
4
 
5
- __version__ = "0.0.5"
5
+ __version__ = "0.2.0b1"
6
+
7
+ from labfreed.pac_id import PAC_ID # noqa: F401
8
+ from labfreed.labfreed_infrastructure import LabFREED_ValidationError # noqa: F401
@@ -0,0 +1,276 @@
1
+ from enum import Enum, auto
2
+ import logging
3
+ import re
4
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
5
+ from typing import Any, List, Set
6
+
7
+ from rich import print
8
+ from rich.table import Table
9
+
10
+ ''' Configure pdoc'''
11
+ __all__ = ["LabFREED_BaseModel", "ValidationMessage", "ValidationMsgLevel", "LabFREED_ValidationError"]
12
+
13
+ class PDOC_Workaround_Base(BaseModel):
14
+ '''@private
15
+ This class only exists to make pdoc work better with Pydantic models. It is set up such, that some things are not showing up in docu
16
+ '''
17
+ model_config = ConfigDict(extra="forbid")
18
+ """@private"""
19
+ def model_post_init(self, context: Any) -> None:
20
+ '''@private'''
21
+ super().model_post_init(context)
22
+
23
+
24
+
25
+
26
+
27
+ class ValidationMsgLevel(Enum):
28
+ '''
29
+ Level of validation messages
30
+ '''
31
+ ERROR = auto()
32
+ '''Model is **invalid**'''
33
+ WARNING = auto()
34
+ '''Model is **valid**, but has issues'''
35
+ RECOMMENDATION = auto()
36
+ '''Model is **valid**, but recommendations apply'''
37
+ INFO = auto()
38
+ '''Model is **valid**. Something of interest was detected, which is not a recommendation.'''
39
+
40
+ class ValidationMessage(PDOC_Workaround_Base):
41
+ '''
42
+ Represents one problem in the model
43
+ '''
44
+ source_id:int
45
+ source:str
46
+ level: ValidationMsgLevel
47
+ msg:str
48
+ highlight:str = "" #this can be used to highlight problematic parts
49
+ highlight_sub_patterns:list[str] = Field(default_factory=list)
50
+
51
+ @field_validator('highlight_sub_patterns', mode='before')
52
+ @classmethod
53
+ def _ensure_list(cls, v):
54
+ if isinstance(v, str):
55
+ return [v]
56
+ return v
57
+
58
+
59
+
60
+ class LabFREED_ValidationError(ValueError):
61
+ '''Error which is raised, when LabFREED validation fails, i.e. when the model contains at least one error.'''
62
+
63
+ def __init__(self, message=None, validation_msgs=None):
64
+ '''@private'''
65
+ super().__init__(message)
66
+ self._validation_msgs = validation_msgs
67
+
68
+ @property
69
+ def validation_msgs(self):
70
+ ''' The validation messages (errors, recommendations, info) present in the invalid model'''
71
+ return self._validation_msgs
72
+
73
+
74
+
75
+
76
+ class LabFREED_BaseModel(PDOC_Workaround_Base):
77
+ """ Extension of Pydantic BaseModel, so that validator can issue warnings.
78
+ The purpose of that is to allow only minimal validation but on top check for stricter recommendations"""
79
+
80
+ _validation_messages: list[ValidationMessage] = PrivateAttr(default_factory=list)
81
+ """Validation messages for this model"""
82
+
83
+ @property
84
+ def is_valid(self) -> bool:
85
+ return len(self.errors()) == 0
86
+
87
+
88
+ def validation_messages(self, nested=True) -> list[ValidationMessage]:
89
+ if nested:
90
+ msgs = self._get_nested_validation_messages()
91
+ else:
92
+ msgs = self._validation_messages
93
+ return msgs
94
+
95
+ def errors(self, nested=True) -> list[ValidationMessage]:
96
+ return _filter_errors(self.validation_messages(nested=nested))
97
+
98
+ def warnings(self, nested=True) -> list[ValidationMessage]:
99
+ return _filter_warnings(self.validation_messages(nested=nested))
100
+
101
+
102
+ def _add_validation_message(self, *, msg: str, level:ValidationMsgLevel, source:str="", highlight_pattern="", highlight_sub=None):
103
+ if not highlight_sub:
104
+ highlight_sub = []
105
+ w = ValidationMessage(msg=msg, source=source, level=level, highlight=highlight_pattern, highlight_sub_patterns=highlight_sub, source_id=id(self))
106
+
107
+ if w not in self._validation_messages:
108
+ self._validation_messages.append(w)
109
+
110
+ # Function to extract warnings from a model and its nested models
111
+ def _get_nested_validation_messages(self, parent_name: str = "", visited: Set[int] = None) -> List['ValidationMessage']:
112
+ """
113
+ Recursively extract warnings from a Pydantic model and its nested fields, including computed fields.
114
+
115
+ :param parent_name: The name of the parent model to track the path.
116
+ :param visited: Set of visited object IDs to prevent infinite loops.
117
+ :return: List of ValidationMessages from this and nested models.
118
+ """
119
+ if visited is None:
120
+ visited = set()
121
+
122
+ model_id = id(self)
123
+ if model_id in visited:
124
+ return []
125
+ visited.add(model_id)
126
+
127
+ warnings_list = [warning for warning in self.validation_messages(nested=False)]
128
+
129
+ # Traverse regular fields
130
+ for field_name, field in self.__fields__.items():
131
+ full_path = f"{parent_name}.{field_name}" if parent_name else field_name
132
+ value = getattr(self, field_name)
133
+
134
+ if isinstance(value, LabFREED_BaseModel):
135
+ warnings_list.extend(value._get_nested_validation_messages(full_path, visited))
136
+ elif isinstance(value, list):
137
+ for index, item in enumerate(value):
138
+ if isinstance(item, LabFREED_BaseModel):
139
+ list_path = f"{full_path}[{index}]"
140
+ warnings_list.extend(item._get_nested_validation_messages(list_path, visited))
141
+
142
+ # Traverse computed fields
143
+ computed_fields = getattr(self, '__pydantic_decorators__', {}).computed_fields or {}
144
+ for field_name in computed_fields:
145
+ full_path = f"{parent_name}.{field_name}" if parent_name else field_name
146
+ try:
147
+ value = getattr(self, field_name)
148
+ except Exception:
149
+ continue # Safely skip computed properties that raise errors
150
+
151
+ if isinstance(value, LabFREED_BaseModel):
152
+ warnings_list.extend(value._get_nested_validation_messages(full_path, visited))
153
+ elif isinstance(value, list):
154
+ for index, item in enumerate(value):
155
+ if isinstance(item, LabFREED_BaseModel):
156
+ list_path = f"{full_path}[{index}]"
157
+ warnings_list.extend(item._get_nested_validation_messages(list_path, visited))
158
+
159
+ return warnings_list
160
+
161
+
162
+
163
+ def _emphasize_in(self, validation_msg, validation_node_str:str, fmt, color='black'):
164
+ if validation_msg.highlight_sub_patterns:
165
+ replacements = validation_msg.highlight_sub_patterns
166
+ else:
167
+ replacements = [validation_msg.highlight]
168
+ # Sort patterns by length descending to avoid subpattern clobbering
169
+ sorted_patterns = sorted(replacements, key=len, reverse=True)
170
+ # Escape the patterns for regex safety
171
+ escaped_patterns = [re.escape(p) for p in sorted_patterns]
172
+ # Create one regex pattern with alternation (longest first)
173
+ pattern = re.compile("|".join(escaped_patterns))
174
+
175
+ out = pattern.sub(lambda m: fmt(m.group(0)), validation_node_str)
176
+ return out
177
+
178
+
179
+ def print_validation_messages(self, target='console'):
180
+ msgs = self._get_nested_validation_messages()
181
+
182
+ table = Table(title="Validation Results", show_header=False, title_justify='left')
183
+
184
+ def col(s):
185
+ return table.add_column(s, vertical='top')
186
+ col("-")
187
+
188
+
189
+ if not msgs:
190
+ table.add_row('All clear!', end_section=True)
191
+ return
192
+
193
+ for m in msgs:
194
+ if m.level == ValidationMsgLevel.ERROR:
195
+ color = 'red'
196
+ else:
197
+ color = 'yellow'
198
+
199
+ match target:
200
+ case 'markdown':
201
+ fmt = lambda s: f'👉{s}👈' # noqa: E731
202
+ case 'console':
203
+ fmt = lambda s: f'[{color} bold]{s}[/{color} bold]' # noqa: E731
204
+ case 'html':
205
+ fmt = lambda s: f'<span class="val_{color}">{s}</span>' # noqa: E731
206
+ case 'html_styled':
207
+ fmt = lambda s: f'<b style="color:{color}>{s}</b>' # noqa: E731
208
+
209
+ serialized = str(self)
210
+ emphazised_highlight = self._emphasize_in(m, serialized, fmt=fmt, color=color)
211
+ emphazised_highlight = emphazised_highlight.replace('👈👉','') # removes two consecutive markers, to make it cleaner
212
+
213
+ txt = f'[bold {color}]{m.level.name} [/bold {color}] in {m.source}'
214
+ txt += '\n' + f'{m.msg}'
215
+ txt += '\n\n' + emphazised_highlight
216
+
217
+ table.add_row( txt)
218
+ table.add_section()
219
+
220
+ logging.info(table)
221
+ print(table)
222
+
223
+
224
+
225
+
226
+
227
+ # def print_validation_messages_(self, str_to_highlight_in=None, target='console'):
228
+ # if not str_to_highlight_in:
229
+ # str_to_highlight_in = str(self)
230
+ # msgs = self.get_nested_validation_messages()
231
+ # print('\n'.join(['\n',
232
+ # '=======================================',
233
+ # 'Validation Results',
234
+ # '---------------------------------------'
235
+ # ]
236
+ # )
237
+ # )
238
+
239
+ # if not msgs:
240
+ # print('All clear!')
241
+ # return
242
+
243
+ # for m in msgs:
244
+ # if m.level.casefold() == "error":
245
+ # color = 'red'
246
+ # else:
247
+ # color = 'yellow'
248
+
249
+ # text = Text.from_markup(f'\n [bold {color}]{m.level} [/bold {color}] in \t {m.source}' )
250
+ # print(text)
251
+ # match target:
252
+ # case 'markdown':
253
+ # formatted_highlight = m.emphazised_highlight.replace('emph', f'🔸').replace('[/', '').replace('[', '').replace(']', '')
254
+ # case 'console':
255
+ # formatted_highlight = m.emphazised_highlight.replace('emph', f'bold {color}')
256
+ # case 'html':
257
+ # formatted_highlight = m.emphazised_highlight.replace('emph', f'b').replace('[', '<').replace(']', '>')
258
+ # fmtd = str_to_highlight_in.replace(m.highlight, formatted_highlight)
259
+ # fmtd = Text.from_markup(fmtd)
260
+ # print(fmtd)
261
+ # print(Text.from_markup(f'{m.problem_msg}'))
262
+
263
+
264
+
265
+
266
+
267
+ def _filter_errors(val_msg:list[ValidationMessage]) -> list[ValidationMessage]:
268
+ return [ m for m in val_msg if m.level == ValidationMsgLevel.ERROR ]
269
+
270
+ def _filter_warnings(val_msg:list[ValidationMessage]) -> list[ValidationMessage]:
271
+ return [ m for m in val_msg if m.level != ValidationMsgLevel.ERROR ]
272
+
273
+ def _quote_texts(texts:list[str]):
274
+ return ','.join([f"'{t}'" for t in texts])
275
+
276
+
@@ -0,0 +1,17 @@
1
+ from .pac_cat import PAC_CAT
2
+ from .category_base import Category
3
+ from .predefined_categories import Material_Device, Material_Substance, Material_Consumable, Material_Misc, Data_Method, Data_Result, Data_Progress, Data_Calibration, Data_Abstract
4
+
5
+ __all__ = [
6
+ "PAC_CAT",
7
+ "Category",
8
+ "Material_Device",
9
+ "Material_Substance",
10
+ "Material_Consumable",
11
+ "Material_Misc",
12
+ "Data_Method",
13
+ "Data_Result",
14
+ "Data_Progress",
15
+ "Data_Calibration",
16
+ "Data_Abstract"
17
+ ]
@@ -0,0 +1,51 @@
1
+
2
+ from typing import Any
3
+ from pydantic import PrivateAttr, computed_field, model_validator
4
+ from labfreed.labfreed_infrastructure import LabFREED_BaseModel, ValidationMsgLevel
5
+ from labfreed.pac_id.id_segment import IDSegment
6
+
7
+
8
+ class Category(LabFREED_BaseModel):
9
+ '''
10
+ Represents a category. \n
11
+ This is the base class for categories. If possible a more specific category should be used.
12
+ '''
13
+ key:str
14
+ '''The category key, e.g. "-MD"'''
15
+ _segments: list[IDSegment] = PrivateAttr(default_factory=list)
16
+
17
+
18
+ @computed_field
19
+ @property
20
+ def segments(self) -> list[IDSegment]:
21
+ return self._segments
22
+
23
+ def __init__(self, **data: Any):
24
+ '''@private'''
25
+ # Pop the user-provided value for computed segments
26
+ input_segments = data.pop("segments", None)
27
+ super().__init__(**data)
28
+ self._segments = input_segments
29
+
30
+ @model_validator(mode='after')
31
+ def _warn_unusual_category_key(self):
32
+ ''' this base class is instantiated only if the key is not a known category key'''
33
+ if type(self) is Category:
34
+ self._add_validation_message(
35
+ source=f"Category {self.key}",
36
+ level = ValidationMsgLevel.RECOMMENDATION,
37
+ msg=f'Category key {self.key} is not a well known key. It is recommended to use well known keys only',
38
+ highlight_pattern = f"{self.key}"
39
+ )
40
+ return self
41
+
42
+
43
+ def __str__(self):
44
+ s = '\n'.join( [f'{field_name} \t ({field_info.alias or ''}): \t {getattr(self, field_name)}' for field_name, field_info in self.model_fields.items() if getattr(self, field_name)])
45
+ return s
46
+
47
+
48
+
49
+
50
+
51
+
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations # optional in 3.11, but recommended for consistency
2
+
3
+ from typing import Self
4
+ from pydantic import computed_field, model_validator
5
+
6
+ from rich import print
7
+ from rich.text import Text
8
+ from rich.table import Table
9
+
10
+ from labfreed.labfreed_infrastructure import ValidationMsgLevel
11
+
12
+ from labfreed.pac_cat.category_base import Category
13
+ from labfreed.pac_cat.predefined_categories import Data_Calibration, Data_Method, Data_Progress, Data_Result, Material_Consumable, Material_Device, Material_Misc, Material_Substance, Data_Static
14
+ from labfreed.pac_id.id_segment import IDSegment
15
+ from labfreed.pac_id.pac_id import PAC_ID
16
+
17
+ ''' Configure pdoc'''
18
+
19
+
20
+ class PAC_CAT(PAC_ID):
21
+ '''
22
+ Extends a PAC-ID with interpretation of the identifier as categories
23
+ '''
24
+ @computed_field
25
+ @property
26
+ def categories(self) -> list[Category]:
27
+ '''The categories present in the PAC-ID's identifier'''
28
+ category_segments = self._split_segments_by_category(self.identifier)
29
+ categories = list()
30
+ for c in category_segments:
31
+ categories.append(self._cat_from_cat_segments(c))
32
+ return categories
33
+
34
+
35
+
36
+ def get_category(self, key) -> Category:
37
+ """Helper to get a category by key
38
+ """
39
+ tmp = [c for c in self.categories if c.key == key]
40
+ if not tmp:
41
+ return None
42
+ return tmp[0]
43
+
44
+
45
+ @classmethod
46
+ def from_categories(cls, issuer:str, categories:list[Category]) -> PAC_CAT:
47
+ identifier = list()
48
+ for category in categories:
49
+ identifier.append(IDSegment(value=category.key))
50
+ identifier.extend(category.segments)
51
+ return PAC_CAT(issuer=issuer, identifier=identifier)
52
+
53
+
54
+ @classmethod
55
+ def from_pac_id(cls, pac_id:PAC_ID) -> Self:
56
+ '''Constructs a PAC-CAT from a PAC-ID'''
57
+ return PAC_CAT(issuer=pac_id.issuer, identifier=pac_id.identifier)
58
+
59
+
60
+
61
+ def to_pac_id(self) -> PAC_ID:
62
+ return PAC_ID(issuer=self.issuer, identifier=self.identifier)
63
+
64
+
65
+ @classmethod
66
+ def _cat_from_cat_segments(cls, segments:list[IDSegment]) -> Category:
67
+ segments = segments.copy()
68
+ category_key = segments[0].value
69
+ segments.pop(0)
70
+
71
+ mapping = {
72
+ '-MD': Material_Device,
73
+ '-MS': Material_Substance,
74
+ '-MC': Material_Consumable,
75
+ '-MM': Material_Misc,
76
+ '-DM': Data_Method,
77
+ '-DR': Data_Result,
78
+ '-DC': Data_Calibration,
79
+ '-DP': Data_Progress,
80
+ '-DS': Data_Static
81
+ }
82
+ known_cat = mapping.get(category_key)
83
+
84
+ if not known_cat:
85
+ return Category(key=category_key, segments=segments)
86
+
87
+ # implicit segment keys
88
+ model_dict = {v.alias: None for k, v in known_cat.model_fields.items() if v.alias and k not in ['key','additional_segments']}
89
+ for k, seg in zip(model_dict.keys(), segments.copy()):
90
+ if seg.key:
91
+ break
92
+ model_dict[k] = seg.value
93
+ segments.pop(0)
94
+
95
+ # try to fill model keys if not already set
96
+ for s in segments.copy():
97
+ if s.key in model_dict.keys() and not model_dict.get(s.key):
98
+ model_dict[s.key] = s.value
99
+ segments.remove(s)
100
+
101
+ model_dict['additional_segments'] = segments
102
+ model_dict['key'] = category_key
103
+ cat= known_cat(**model_dict)
104
+ return cat
105
+
106
+ @staticmethod
107
+ def _split_segments_by_category(segments:list[IDSegment]) -> list[list[IDSegment]]:
108
+ category_segments = list()
109
+ c = list()
110
+ for s in segments:
111
+ # new category starts with "-"
112
+ if s.value[0] == '-':
113
+ c = [s]
114
+ category_segments.append(c)
115
+ else:
116
+ c.append(s)
117
+
118
+ # first cat can be empty > remove
119
+ category_segments = [c for c in category_segments if len(c) > 0]
120
+
121
+ return category_segments
122
+
123
+
124
+ @model_validator(mode='after')
125
+ def _check_keys_are_unique_in_each_category(self) -> Self:
126
+ for c in self.categories:
127
+ keys = [s.key for s in c.segments if s.key]
128
+ duplicate_keys = [k for k in set(keys) if keys.count(k) > 1]
129
+ if duplicate_keys:
130
+ for k in duplicate_keys:
131
+ self._add_validation_message(
132
+ source=f"identifier {k}",
133
+ level = ValidationMsgLevel.ERROR,
134
+ msg=f"Duplicate key {k} in category {c.key}",
135
+ highlight_pattern = k
136
+ )
137
+ return self
138
+
139
+ def print_categories(self):
140
+ table = Table(title=f'Categories in {str(self)}', show_header=False)
141
+ table.add_column('0')
142
+ table.add_column('1')
143
+ for i, c in enumerate(self.categories):
144
+ if i == 0:
145
+ title = Text('Main Category', style='bold')
146
+ else:
147
+ title = Text('Category', style='bold')
148
+
149
+ table.add_row(title)
150
+
151
+ for field_name, field_info in c.model_fields.items():
152
+ if not getattr(c, field_name):
153
+ continue
154
+ table.add_row(f"{field_name} ({field_info.alias or ''})",
155
+ f" {getattr(c, field_name)}"
156
+ )
157
+ table.add_section()
158
+ print(table)
159
+