labfreed 0.2.12__py3-none-any.whl → 0.3.0a1__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 (43) hide show
  1. labfreed/__init__.py +1 -1
  2. labfreed/labfreed_extended/app/app_infrastructure.py +104 -0
  3. labfreed/labfreed_extended/app/pac_info.py +79 -0
  4. labfreed/labfreed_extended/pac_attributes/py_attributes.py +123 -0
  5. labfreed/labfreed_extended/pac_attributes/server/attribute_server_factory.py +103 -0
  6. labfreed/labfreed_extended/pac_attributes/server/excel_attribute_data_source.py +128 -0
  7. labfreed/labfreed_extended/utilities/formatted_print.py +64 -0
  8. labfreed/labfreed_infrastructure.py +3 -3
  9. labfreed/pac_attributes/api_data_models/request.py +56 -0
  10. labfreed/pac_attributes/api_data_models/response.py +184 -0
  11. labfreed/pac_attributes/api_data_models/server_capabilities_response.py +7 -0
  12. labfreed/pac_attributes/client/__init__.py +1 -0
  13. labfreed/pac_attributes/client/attribute_cache.py +78 -0
  14. labfreed/pac_attributes/client/client.py +148 -0
  15. labfreed/pac_attributes/server/attribute_data_sources.py +69 -0
  16. labfreed/pac_attributes/server/server.py +237 -0
  17. labfreed/pac_attributes/server/translation_data_sources.py +60 -0
  18. labfreed/pac_attributes/well_knonw_attribute_keys.py +11 -0
  19. labfreed/pac_cat/category_base.py +19 -0
  20. labfreed/pac_cat/pac_cat.py +1 -1
  21. labfreed/pac_id/extension.py +32 -7
  22. labfreed/pac_id/pac_id.py +2 -2
  23. labfreed/pac_id/url_parser.py +4 -4
  24. labfreed/pac_id/url_serializer.py +11 -5
  25. labfreed/pac_id_resolver/cit_common.py +1 -1
  26. labfreed/pac_id_resolver/cit_v1.py +0 -3
  27. labfreed/pac_id_resolver/cit_v2.py +1 -6
  28. labfreed/pac_id_resolver/resolver.py +12 -7
  29. labfreed/pac_id_resolver/services.py +0 -1
  30. labfreed/trex/python_convenience/quantity.py +86 -5
  31. labfreed/trex/value_segments.py +1 -1
  32. labfreed/utilities/ensure_utc_time.py +6 -0
  33. labfreed/utilities/translations.py +60 -0
  34. labfreed/well_known_extensions/display_name_extension.py +3 -3
  35. labfreed/well_known_keys/gs1/gs1_ai_enum_sorted.py +4 -0
  36. labfreed/well_known_keys/labfreed/well_known_keys.py +12 -2
  37. labfreed/well_known_keys/unece/unece_units.py +2 -1
  38. {labfreed-0.2.12.dist-info → labfreed-0.3.0a1.dist-info}/METADATA +8 -1
  39. labfreed-0.3.0a1.dist-info/RECORD +62 -0
  40. labfreed/well_known_keys/gs1/gs1.py +0 -4
  41. labfreed-0.2.12.dist-info/RECORD +0 -45
  42. {labfreed-0.2.12.dist-info → labfreed-0.3.0a1.dist-info}/WHEEL +0 -0
  43. {labfreed-0.2.12.dist-info → labfreed-0.3.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,184 @@
1
+
2
+ from abc import ABC
3
+ from datetime import datetime
4
+ import re
5
+ from typing import Annotated, Any, Literal, Union, get_args
6
+ from labfreed.utilities.ensure_utc_time import ensure_utc
7
+ from labfreed.labfreed_infrastructure import LabFREED_BaseModel, ValidationMsgLevel, _quote_texts
8
+ from pydantic import Field, field_validator, model_validator
9
+
10
+
11
+ class AttributeBase(LabFREED_BaseModel, ABC):
12
+ key: str
13
+ value: Any
14
+ label: str = ""
15
+
16
+ observed_at: datetime | None = None
17
+
18
+ def __init__(self, **data):
19
+ # Automatically inject the Literal value for `type`
20
+ discriminator_value = self._get_discriminator_value()
21
+ data["type"] = discriminator_value
22
+ super().__init__(**data)
23
+
24
+ @field_validator('observed_at', mode='before')
25
+ def set_utc_observed_at_if_naive(cls, value):
26
+ if isinstance(value, datetime):
27
+ return ensure_utc(value)
28
+ else:
29
+ return value
30
+
31
+ @classmethod
32
+ def _get_discriminator_value(cls) -> str:
33
+ """Extract the Literal value from the 'type' annotation."""
34
+ try:
35
+ type_annotation = cls.__annotations__["type"]
36
+ literal_value = get_args(type_annotation)[0]
37
+ return literal_value
38
+ except Exception as e:
39
+ raise TypeError(
40
+ f"{cls.__name__} must define `type: Literal[<value>]` annotation"
41
+ ) from e
42
+
43
+
44
+
45
+
46
+ class ReferenceAttribute(AttributeBase):
47
+ type: Literal["reference"]
48
+ value: str
49
+
50
+ class DateTimeAttribute(AttributeBase):
51
+ type: Literal["datetime"]
52
+ value: datetime
53
+
54
+ @field_validator('value', mode='before')
55
+ def set_utc__if_naive(cls, value):
56
+ if isinstance(value, datetime):
57
+ return ensure_utc(value)
58
+ else:
59
+ return value
60
+
61
+ class BoolAttribute(AttributeBase):
62
+ type: Literal["bool"]
63
+ value: bool
64
+
65
+ class TextAttribute(AttributeBase):
66
+ type: Literal["text"]
67
+ value: str
68
+
69
+
70
+
71
+
72
+ class NumericValue(LabFREED_BaseModel):
73
+ magnitude: str
74
+ unit: str
75
+
76
+ @model_validator(mode='after')
77
+ def _validate_value(self):
78
+ value = self.magnitude
79
+ if not_allowed_chars := set(re.sub(r'[0-9\.\-\+Ee]', '', value)):
80
+ self._add_validation_message(
81
+ source="Numeric Attribute",
82
+ level=ValidationMsgLevel.ERROR, # noqa: F821
83
+ msg=f"Characters {_quote_texts(not_allowed_chars)} are not allowed in quantity segment. Must be a number.",
84
+ highlight_pattern = f'{value}',
85
+ highlight_sub=not_allowed_chars
86
+ )
87
+ if not re.fullmatch(r'-?\d+(\.\d+)?([Ee][\+-]?\d+)?', value):
88
+ self._add_validation_message(
89
+ source="Numeric Attribute",
90
+ level=ValidationMsgLevel.ERROR,
91
+ msg=f"{value} cannot be converted to number",
92
+ highlight_pattern = f'{value}'
93
+ )
94
+ return self
95
+
96
+ @model_validator(mode="after")
97
+ def _validate_units(self):
98
+ '''A sanity check on unit complying with UCUM. NOTE: It is not a complete validation
99
+ - I check for blankspaces and ^, which are often used for units, but are invalid.
100
+ - the general structure of a ucum unit is validated, but 1)parentheses are not matched 2) units are not validated 3)prefixes are not checked
101
+ '''
102
+ if ' ' in self.unit or '^' in self.unit:
103
+ self._add_validation_message(
104
+ source="Numeric Attribute",
105
+ level= ValidationMsgLevel.ERROR,
106
+ msg=f"Unit {self.unit} is invalid. Must not contain blankspace or '^'.",
107
+ highlight_pattern = self.unit
108
+ )
109
+ elif not re.fullmatch(r"^(((?P<unit>[\w\[\]]+?)(?P<exponent>\-?\d+)?|(?P<annotation>)\{\w+?\})(?P<operator>[\./]?)?)+", self.unit):
110
+ self._add_validation_message(
111
+ source="Numeric Attribute",
112
+ level= ValidationMsgLevel.WARNING,
113
+ msg=f"Unit {self.unit} is probably invalid. Ensure it complies with UCUM specifications.",
114
+ highlight_pattern = self.unit
115
+ )
116
+ return self
117
+
118
+
119
+
120
+
121
+ class NumericAttribute(AttributeBase):
122
+ type: Literal["numeric"]
123
+ value: NumericValue
124
+
125
+ class ObjectAttribute(AttributeBase):
126
+ type: Literal["object"]
127
+ value: dict[str, Any]
128
+
129
+
130
+
131
+
132
+ Attribute = Annotated[
133
+ Union[
134
+ ReferenceAttribute,
135
+ DateTimeAttribute,
136
+ BoolAttribute,
137
+ TextAttribute,
138
+ NumericAttribute,
139
+ ObjectAttribute
140
+ ],
141
+ Field(discriminator="type")
142
+ ]
143
+
144
+ VALID_FOREVER = "forever"
145
+
146
+ class AttributeGroup(LabFREED_BaseModel):
147
+ key: str
148
+ label: str = ""
149
+ attributes: list[Attribute]
150
+
151
+ state_of: datetime
152
+ valid_until: datetime | Literal["forever"] | None = None
153
+
154
+ @field_validator('valid_until', mode='before')
155
+ def set_utc_valid_until_if_naive(cls, value):
156
+ if isinstance(value, datetime):
157
+ return ensure_utc(value)
158
+ else:
159
+ return value
160
+
161
+
162
+ class AttributesOfPACID(LabFREED_BaseModel):
163
+ pac_url: str
164
+ attribute_groups: list[AttributeGroup]
165
+
166
+
167
+
168
+ class AttributeResponsePayload(LabFREED_BaseModel):
169
+ schema_version: str = Field(default='1.0')
170
+ language:str
171
+ pac_attributes: list[AttributesOfPACID]
172
+
173
+ def to_json(self):
174
+ return self.model_dump_json(exclude_none=True)
175
+
176
+
177
+
178
+
179
+
180
+
181
+
182
+
183
+
184
+
@@ -0,0 +1,7 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class ServerCapabilities(BaseModel):
5
+ supported_languages: list[str]
6
+ default_language: str
7
+ available_attribute_groups: list[str]
@@ -0,0 +1 @@
1
+ import labfreed.utilities.translations
@@ -0,0 +1,78 @@
1
+
2
+
3
+ from datetime import datetime
4
+ from typing import Literal, Protocol
5
+
6
+
7
+ from labfreed.pac_attributes.api_data_models.response import AttributeGroup
8
+ from labfreed.pac_id.pac_id import PAC_ID
9
+
10
+
11
+
12
+ class CacheableAttributeGroup(AttributeGroup):
13
+ origin:str
14
+ language:str
15
+ valid_until: Literal['forever'] | datetime | None = None
16
+
17
+ # @model_validator(mode='after')
18
+ # def set_valid_until(self) -> 'CacheableAttributeGroup':
19
+ # vals = [a.valid_until for a in self.attributes]
20
+ # if all(e == 'forever' for e in vals):
21
+ # self.valid_until = 'forever'
22
+ # elif any(e is None for e in vals):
23
+ # self.valid_until = None
24
+ # else:
25
+ # self.valid_until = min(v for v in vals if isinstance(v, datetime))
26
+ # return self
27
+
28
+
29
+ @property
30
+ def still_valid(self):
31
+ if self.valid_until is None:
32
+ return False
33
+ if self.valid_until == 'forever':
34
+ return True
35
+
36
+ return self.valid_until > datetime.now()
37
+
38
+
39
+
40
+
41
+ class AttributeCache(Protocol):
42
+ def get_all(self, service_url:str, pac:PAC_ID) -> list[CacheableAttributeGroup]:
43
+ pass
44
+
45
+ def get_attribute_groups(self, service_url:str, pac:PAC_ID, attribute_groups:list[str]):
46
+ pass
47
+
48
+ def update(self, service_url:str, pac:PAC_ID, attribute_groups:list[CacheableAttributeGroup]):
49
+ pass
50
+
51
+
52
+
53
+ class MemoryAttributeCache(AttributeCache):
54
+ '''simple in-memory implementation of AttributeCache'''
55
+ def __init__(self):
56
+ self._store = dict()
57
+
58
+ def get_all(self, service_url:str, pac:PAC_ID|str) -> list[CacheableAttributeGroup]:
59
+ if isinstance(pac, str):
60
+ pac = PAC_ID.from_url(pac)
61
+ k = self._generate_dict_key(service_url=service_url, pac=pac)
62
+
63
+ ags = [CacheableAttributeGroup.model_validate(e) for e in self._store.get(k, [])]
64
+ return ags
65
+
66
+ def get_attribute_groups(self, service_url:str, pac:PAC_ID, attribute_groups:list[str]):
67
+ all_ags = self.get_all(service_url=service_url, pac=pac)
68
+ selected_ags = [ag for ag in all_ags if ag.key in attribute_groups]
69
+ return selected_ags
70
+
71
+ def update(self, service_url:str, pac:PAC_ID, attribute_groups: list[CacheableAttributeGroup] ):
72
+ k = self._generate_dict_key(service_url=service_url, pac=pac)
73
+ self._store.update({k: [e.model_dump() for e in attribute_groups]})
74
+
75
+ @staticmethod
76
+ def _generate_dict_key(service_url:str, pac:PAC_ID):
77
+ key = service_url +";"+ pac.to_url(include_extensions=False)
78
+ return key
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Protocol, runtime_checkable
5
+
6
+ import requests
7
+
8
+ from pydantic import ValidationError
9
+
10
+ from labfreed.pac_attributes.api_data_models.request import AttributeRequestPayload
11
+ from labfreed.pac_attributes.api_data_models.response import AttributeResponsePayload
12
+ from labfreed.pac_attributes.client.attribute_cache import AttributeCache, CacheableAttributeGroup
13
+ from labfreed.pac_id.pac_id import PAC_ID
14
+
15
+
16
+ class AuthenticationError(Exception):
17
+ '''Server rejected authentication. '''
18
+ pass
19
+
20
+ class AttributeClientInternalError(Exception):
21
+ '''The error is on client side. Possibly a bug on client side'''
22
+ pass
23
+
24
+ class AttributeServerError(Exception):
25
+ '''The error is on server side. Needs to contact the server admin'''
26
+ pass
27
+
28
+
29
+
30
+ @runtime_checkable
31
+ class AttributeRequestCallback(Protocol):
32
+ def __call__(self, url: str, attribute_request_body: str) -> tuple[int, str]:
33
+ '''handle the request
34
+ returns a tuple of HTTP status code and the body of the response or an error message'''
35
+ ...
36
+
37
+
38
+ def attribute_request_default_callback_factory(session: requests.Session = None) -> AttributeRequestCallback:
39
+ """ Returns a default implementation of AttributeRequestCallback using `requests` package.
40
+
41
+ Args:
42
+ session (requests.Session, optional): A requests.Session object. Pass such an object if you need control over authentication and such things. If omitted a default Session is used.
43
+
44
+ Returns:
45
+ AttributeRequestCallback: a callback following the AttributeRequestCallback protocol.
46
+ """
47
+ if session is None:
48
+ session = requests.Session()
49
+
50
+ def callback(url: str, attribute_request_body: str) -> tuple[int, str]:
51
+ try:
52
+ resp = session.post(url, data=attribute_request_body, headers={'Content-Type': 'application/json'})
53
+ return resp.status_code, resp.text
54
+ except requests.exceptions.RequestException as e:
55
+ return 500, str(e)
56
+ return callback
57
+
58
+
59
+
60
+
61
+ @dataclass
62
+ class AttributeClient():
63
+ """ Client handling attribute requests and caching thereof.
64
+ """
65
+
66
+ http_post_callback:AttributeRequestCallback
67
+ cache_store:AttributeCache
68
+
69
+ def get_attributes(self,
70
+ server_url:str,
71
+ pac_id:PAC_ID|str,
72
+ restrict_to_attribute_groups:list[str]|None=None,
73
+ language_preferences:list[str]|None=None,
74
+ force_server_request=False
75
+ ) -> list[CacheableAttributeGroup]:
76
+ """gets the attributes from one attribute server for one PAC-ID. Uses a cached version if possible, otherwise requests from the server again.
77
+
78
+ Args:
79
+ server_url (str): _description_
80
+ pac_id (PAC_ID | str): _description_
81
+ restrict_to_attribute_groups (list[str] | None, optional): _description_. Defaults to None.
82
+ language_preferences (list[str] | None, optional): _description_. Defaults to None.
83
+ force_server_request (bool, optional): _description_. Defaults to False.
84
+
85
+ Raises:
86
+ AuthenticationError:
87
+ AttributeClientInternalError:
88
+ AttributeServerError:
89
+
90
+ Returns:
91
+ list[CacheableAttributeGroup]: attribute groups for the PAC-ID
92
+ """
93
+ if isinstance(pac_id, str):
94
+ pac_id = PAC_ID.from_url(pac_id)
95
+
96
+ # try the cache
97
+ if not force_server_request:
98
+ if restrict_to_attribute_groups:
99
+ attribute_groups = self.cache_store.get_attribute_groups(server_url, pac_id, restrict_to_attribute_groups)
100
+ else:
101
+ attribute_groups = self.cache_store.get_all(server_url, pac_id)
102
+
103
+ if attribute_groups and all([ag.still_valid for ag in attribute_groups]):
104
+ return attribute_groups
105
+
106
+ # no valid data found in cache > request to server
107
+ attribute_request_body = AttributeRequestPayload(pac_urls=[pac_id.to_url()],
108
+ restrict_to_attribute_groups=restrict_to_attribute_groups,
109
+ language_preferences=language_preferences
110
+ )
111
+ response_code, response_body_str = self.http_post_callback(server_url, attribute_request_body.model_dump_json())
112
+
113
+ if response_code == 400:
114
+ raise AttributeClientInternalError(f"The server did not accept the request. Server message: '{response_body_str}'")
115
+ if response_code == 401:
116
+ raise AuthenticationError(f"Failed to authorize at the server. Server message: {response_body_str}")
117
+ if response_code == 500:
118
+ raise AttributeServerError(f"The server accepted the request, but encountered an internal error. Contact the server admin. Server message: {response_body_str}")
119
+ try:
120
+ r = AttributeResponsePayload.model_validate_json(response_body_str)
121
+ except ValidationError as e:
122
+ print(e)
123
+ raise AttributeServerError("The server accepted the request, and sent a reponse. However, the response is adhering to the PAC Attributes specifications. Contact the server admin.")
124
+
125
+
126
+ # update cache
127
+ for ag_for_pac in r.pac_attributes:
128
+ pac = PAC_ID.from_url(ag_for_pac.pac_url)
129
+ ags = [
130
+ CacheableAttributeGroup(
131
+ key= ag.key,
132
+ attributes=ag.attributes,
133
+ origin=server_url,
134
+ language=r.language,
135
+ label=ag.label,
136
+ state_of=ag.state_of)
137
+ for ag in ag_for_pac.attribute_groups
138
+ ]
139
+ self.cache_store.update(server_url, pac, ags)
140
+
141
+ if pac_id == pac:
142
+ attribute_groups_out = ags
143
+ return attribute_groups_out
144
+
145
+
146
+
147
+
148
+
@@ -0,0 +1,69 @@
1
+ from abc import ABC, abstractmethod, abstractproperty
2
+ from datetime import datetime, timezone
3
+ from labfreed.pac_attributes.api_data_models.response import VALID_FOREVER, AttributeGroup
4
+ from labfreed_extended.pac_attributes.py_attributes import pyAttributes
5
+
6
+
7
+ class AttributeGroupDataSource(ABC):
8
+
9
+ def __init__(self, attribute_group_key:str, include_extensions:bool=False, is_static:bool=False, ):
10
+ self._attribute_group_key = attribute_group_key
11
+ self._include_extensions = include_extensions
12
+ self._is_static = is_static
13
+
14
+ @property
15
+ def is_static(self) -> bool:
16
+ return self._is_static
17
+
18
+
19
+ @property
20
+ def attribute_group_key(self):
21
+ return self._attribute_group_key
22
+
23
+ @abstractproperty
24
+ def provides_attributes(self):
25
+ pass
26
+
27
+ @abstractmethod
28
+ def attributes(self, pac_url: str) -> AttributeGroup:
29
+ pass
30
+
31
+
32
+ class Dict_DataSource(AttributeGroupDataSource):
33
+ def __init__(self, data:dict[str, pyAttributes], *args, **kwargs):
34
+ if not all([isinstance(e, pyAttributes) for e in data.values()]):
35
+ raise ValueError('Invalid data')
36
+ self._data:pyAttributes = data
37
+ self._state_of = datetime.now(tz=timezone.utc)
38
+
39
+ super().__init__(*args, **kwargs)
40
+
41
+
42
+ @property
43
+ def provides_attributes(self):
44
+ return [a.key for attributes in self._data.values() for a in attributes.root]
45
+
46
+
47
+ def attributes(self, pac_url: str) -> AttributeGroup:
48
+ if not self._include_extensions:
49
+ pac_url = pac_url.split('*')[0]
50
+
51
+ attributes:pyAttributes = self._data.get(pac_url)
52
+ if not attributes:
53
+ return None
54
+ attributes = attributes.to_payload_attributes()
55
+
56
+
57
+ valid_until = VALID_FOREVER if self._is_static else None
58
+
59
+
60
+ return AttributeGroup(key=self._attribute_group_key,
61
+ attributes=attributes,
62
+ state_of=self._state_of,
63
+ valid_until=valid_until)
64
+
65
+
66
+
67
+
68
+
69
+