labfreed 0.0.3__tar.gz → 0.0.5__tar.gz

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 (29) hide show
  1. {labfreed-0.0.3 → labfreed-0.0.5}/PKG-INFO +22 -3
  2. labfreed-0.0.5/README.md +21 -0
  3. {labfreed-0.0.3 → labfreed-0.0.5}/labfreed/PAC_CAT/data_model.py +5 -5
  4. labfreed-0.0.5/labfreed/PAC_ID/data_model.py +215 -0
  5. {labfreed-0.0.3 → labfreed-0.0.5}/labfreed/PAC_ID/parse.py +14 -5
  6. {labfreed-0.0.3 → labfreed-0.0.5}/labfreed/PAC_ID/serialize.py +5 -2
  7. labfreed-0.0.5/labfreed/PAC_ID/well_known_segment_keys.py +16 -0
  8. {labfreed-0.0.3 → labfreed-0.0.5}/labfreed/TREXExtension/unit_utilities.py +13 -4
  9. {labfreed-0.0.3 → labfreed-0.0.5}/labfreed/__init__.py +1 -1
  10. labfreed-0.0.5/labfreed/validation.py +71 -0
  11. {labfreed-0.0.3 → labfreed-0.0.5}/pyproject.toml +2 -2
  12. {labfreed-0.0.3 → labfreed-0.0.5}/tests/test_PAC_ID/test_pac_id_parse.py +48 -42
  13. {labfreed-0.0.3 → labfreed-0.0.5}/tests/test_PAC_ID/test_pac_id_serialization.py +17 -1
  14. labfreed-0.0.3/README.md +0 -3
  15. labfreed-0.0.3/labfreed/PAC_ID/data_model.py +0 -114
  16. {labfreed-0.0.3 → labfreed-0.0.5}/.vscode/launch.json +0 -0
  17. {labfreed-0.0.3 → labfreed-0.0.5}/.vscode/settings.json +0 -0
  18. {labfreed-0.0.3 → labfreed-0.0.5}/LICENSE +0 -0
  19. {labfreed-0.0.3 → labfreed-0.0.5}/labfreed/DisplayNameExtension/DisplayNameExtension.py +0 -0
  20. {labfreed-0.0.3 → labfreed-0.0.5}/labfreed/DisplayNameExtension/base36.py +0 -0
  21. {labfreed-0.0.3 → labfreed-0.0.5}/labfreed/PAC_CAT/__init__.py +0 -0
  22. {labfreed-0.0.3 → labfreed-0.0.5}/labfreed/PAC_ID/__init__.py +0 -0
  23. {labfreed-0.0.3 → labfreed-0.0.5}/labfreed/TREXExtension/data_model.py +0 -0
  24. {labfreed-0.0.3 → labfreed-0.0.5}/labfreed/TREXExtension/parse.py +0 -0
  25. {labfreed-0.0.3 → labfreed-0.0.5}/labfreed/TREXExtension/uncertainty.py +0 -0
  26. {labfreed-0.0.3 → labfreed-0.0.5}/main.py +0 -0
  27. {labfreed-0.0.3 → labfreed-0.0.5}/pytest.ini +0 -0
  28. {labfreed-0.0.3 → labfreed-0.0.5}/tests/test_PAC_CAT/test_PAC_CAT.py +0 -0
  29. {labfreed-0.0.3 → labfreed-0.0.5}/tests/test_TREXExtension/test_TREX.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labfreed
3
- Version: 0.0.3
3
+ Version: 0.0.5
4
4
  Summary: Python implementation of LabFREED building blocks
5
- Author-email: Reto Thürer <reto.thuerer@gmail.com>
6
- Requires-Python: >=3.12
5
+ Author-email: Reto Thürer <thuerer.r@buchi.com>
6
+ Requires-Python: >=3.10
7
7
  Description-Content-Type: text/markdown
8
8
  License-Expression: MIT
9
9
  Classifier: Programming Language :: Python :: 3
@@ -13,3 +13,22 @@ License-File: LICENSE
13
13
  # LabFREED for Python
14
14
 
15
15
  A python implementation of [LabFREED](www.labfreed.com).
16
+
17
+
18
+
19
+ # Examples:
20
+
21
+ ## Parse a PAC ID
22
+
23
+
24
+ ## Parse a PAC-ID with extensions
25
+
26
+
27
+
28
+ ## Create a PAC-ID
29
+
30
+ ## Create a PAC-ID and T-REX for a titration curve
31
+
32
+
33
+
34
+
@@ -0,0 +1,21 @@
1
+ # LabFREED for Python
2
+
3
+ A python implementation of [LabFREED](www.labfreed.com).
4
+
5
+
6
+
7
+ # Examples:
8
+
9
+ ## Parse a PAC ID
10
+
11
+
12
+ ## Parse a PAC-ID with extensions
13
+
14
+
15
+
16
+ ## Create a PAC-ID
17
+
18
+ ## Create a PAC-ID and T-REX for a titration curve
19
+
20
+
21
+
@@ -65,23 +65,23 @@ class Material_Misc(Material_Consumable):
65
65
 
66
66
  class Data_Result(CATBase):
67
67
  category_key: str = Field(default='-DR', frozen=True)
68
- id:str = Field( alias='240', min_length=1)
68
+ id:str = Field( alias='21', min_length=1)
69
69
 
70
70
  class Data_Method(CATBase):
71
71
  category_key: str = Field(default='-DM', frozen=True)
72
- id:str = Field( alias='240', min_length=1)
72
+ id:str = Field( alias='21', min_length=1)
73
73
 
74
74
  class Data_Calibration(CATBase):
75
75
  category_key: str = Field(default='-DC', frozen=True)
76
- id:str = Field( alias='240', min_length=1)
76
+ id:str = Field( alias='21', min_length=1)
77
77
 
78
78
  class Data_Progress(CATBase):
79
79
  category_key: str = Field(default='-DP', frozen=True)
80
- id:str = Field( alias='240', min_length=1)
80
+ id:str = Field( alias='21', min_length=1)
81
81
 
82
82
  class Data_Static(CATBase):
83
83
  category_key: str = Field(default='-DS', frozen=True)
84
- id:str = Field( alias='240', min_length=1)
84
+ id:str = Field( alias='21', min_length=1)
85
85
 
86
86
 
87
87
 
@@ -0,0 +1,215 @@
1
+ import re
2
+ from typing import Optional
3
+ from typing_extensions import Self
4
+ from pydantic import Field, ValidationInfo, computed_field, conlist, model_validator, field_validator
5
+ from ..validation import BaseModelWithWarnings, ValidationWarning, hsegment_pattern, domain_name_pattern
6
+ from abc import ABC, abstractproperty, abstractstaticmethod
7
+ from .well_known_segment_keys import WellKnownSegmentKeys
8
+
9
+
10
+ class IDSegment(BaseModelWithWarnings):
11
+ key:str|None = None
12
+ value:str
13
+ @model_validator(mode="after")
14
+ def validate_segment(cls, model):
15
+ key = model.key or ""
16
+ value = model.value
17
+
18
+ # MUST be a valid hsegment according to RFC 1738, but without * (see PAC-ID Extension)
19
+ # This means it must be true for both, key and value
20
+ if not_allowed_chars := set(re.sub(hsegment_pattern, '', key)):
21
+ raise ValueError(f"id segment key {key} contains invalid characters {' '.join(not_allowed_chars)}.")
22
+
23
+ if not_allowed_chars := set(re.sub(hsegment_pattern, '', value)):
24
+ raise ValueError(f"id segment key {value} contains invalid characters {' '.join(not_allowed_chars)}.")
25
+
26
+ # Segment key SHOULD be limited to A-Z, 0-9, and -+..
27
+ if not_recommended_chars := set(re.sub(r'[A-Z0-9-:+]', '', key)):
28
+ model.add_warning(
29
+ source=f"id segment key {key}",
30
+ type="Recommendation",
31
+ msg=f"{' '.join(not_recommended_chars)} should not be used.",
32
+ recommendation = "SHOULD be limited to A-Z, 0-9, and -+",
33
+ highlight_pattern = key,
34
+ highlight_sub = not_recommended_chars
35
+ )
36
+
37
+ # Segment key should be in Well know keys
38
+ if key and key not in [k.value for k in WellKnownSegmentKeys]:
39
+ model.add_warning(
40
+ source=f"id segment key {key}",
41
+ type="Recommendation",
42
+ msg=f"{key} is not a well known segment key.",
43
+ recommendation = "RECOMMENDED to be a well-known id segment key.",
44
+ highlight_pattern = key
45
+ )
46
+
47
+
48
+ # Segment value SHOULD be limited to A-Z, 0-9, and -+..
49
+ if not_recommended_chars := set(re.sub(r'[A-Z0-9-:+]', '', value)):
50
+ model.add_warning(
51
+ source=f"id segment value {value}",
52
+ type="Recommendation",
53
+ msg=f"Characters {' '.join(not_recommended_chars)} should not be used.",
54
+ recommendation = "SHOULD be limited to A-Z, 0-9, and -+",
55
+ highlight_pattern = value,
56
+ highlight_sub = not_recommended_chars
57
+ )
58
+
59
+ # Segment value SHOULD be limited to A-Z, 0-9, and :-+ for new designs.
60
+ # this means that ":" in key or value is problematic
61
+ if ':' in key:
62
+ model.add_warning(
63
+ source=f"id segment key {key}",
64
+ type="Recommendation",
65
+ msg=f"Character ':' should not be used in segment key, since this character is used to separate key and value this can lead to undefined behaviour.",
66
+ highlight_pattern = key
67
+ )
68
+ if ':' in value:
69
+ model.add_warning(
70
+ source=f"id segment value {value}",
71
+ type="Recommendation",
72
+ msg=f"Character ':' should not be used in segment value, since this character is used to separate key and value this can lead to undefined behaviour.",
73
+ highlight_pattern = value
74
+ )
75
+
76
+ return model
77
+
78
+
79
+
80
+
81
+ class Category(BaseModelWithWarnings):
82
+ key:str|None = None
83
+ segments: list[IDSegment]
84
+
85
+
86
+ class Identifier(BaseModelWithWarnings):
87
+ segments: conlist(IDSegment, min_length=1) = Field(..., exclude=True) # type: ignore # exclude=True prevents this from being serialized by Pydantic
88
+
89
+ @computed_field
90
+ @property
91
+ def categories(self) -> list[Category]:
92
+ categories = list()
93
+ c = Category(segments=[])
94
+ categories.append(c)
95
+ for s in self.segments:
96
+ # new category starts with "-"
97
+ if s.value[0] == '-':
98
+ cat_key = s.value
99
+ c = Category(key=cat_key, segments=[])
100
+ categories.append(c)
101
+ else:
102
+ c.segments.append(s)
103
+
104
+ # the first category might have no segments. remove categories without segments
105
+ if not categories[0].segments:
106
+ categories = categories[1:]
107
+
108
+ return categories
109
+
110
+ @model_validator(mode='after')
111
+ def check_keys_are_unique_in_each_category(self) -> Self:
112
+ for c in self.categories:
113
+ keys = [s.key for s in c.segments if s.key]
114
+ duplicate_keys = [k for k in set(keys) if keys.count(k) > 1]
115
+ if duplicate_keys:
116
+ raise ValueError(f'Duplicate keys {",".join(duplicate_keys)} in category {c.key}')
117
+ return self
118
+
119
+ @model_validator(mode='after')
120
+ def check_length(self) -> Self:
121
+ l = 0
122
+ for s in self.segments:
123
+ if s.key:
124
+ l += len(s.key)
125
+ l += 1 # for ":"
126
+ l += len(s.value)
127
+ l += len(self.segments) - 1 # account for "/" separating the segments
128
+
129
+ if l > 256:
130
+ raise ValueError(f'Identifier is {l} characters long, Identifier must not exceed 256 characters.')
131
+ return self
132
+
133
+ @staticmethod
134
+ def from_categories(categories:list[Category]) :
135
+ segments = list()
136
+ for c in categories:
137
+ if c.key:
138
+ segments.append(IDSegment(value=c.key))
139
+ segments.extend(c.segments)
140
+ return Identifier(segments=segments)
141
+
142
+
143
+
144
+ class Extension(ABC, BaseModelWithWarnings):
145
+
146
+ @abstractproperty
147
+ def name(self)->str:
148
+ pass
149
+
150
+ @abstractproperty
151
+ def type(self)->str:
152
+ pass
153
+
154
+ @abstractproperty
155
+ def data(self)->str:
156
+ pass
157
+
158
+ @abstractstaticmethod
159
+ def from_spec_fields(name, type, data):
160
+ pass
161
+
162
+
163
+ class UnknownExtension(Extension):
164
+ name_:str
165
+ type_:str
166
+ data_:str
167
+
168
+ @property
169
+ def name(self)->str:
170
+ return self.name_
171
+
172
+ @property
173
+ def type(self)->str:
174
+ return self.type_
175
+
176
+ @property
177
+ def data(self)->str:
178
+ return self.data_
179
+
180
+ @staticmethod
181
+ def from_spec_fields(name, type, data):
182
+ return UnknownExtension(name_=name, type_=type, data_=data)
183
+
184
+
185
+
186
+ class PACID(BaseModelWithWarnings):
187
+ issuer:str
188
+ identifier: Identifier
189
+
190
+ @model_validator(mode="after")
191
+ def validate_issuer(cls, model):
192
+ if not re.fullmatch(domain_name_pattern, model.issuer):
193
+ raise ValueError("Issuer must be a valid domain name.")
194
+
195
+
196
+ # recommendation that A-Z, 0-9, -, and . should be used
197
+ if not_recommended_chars := set(re.sub(r'[A-Z0-9\.-]', '', model.issuer)):
198
+ model.add_warning(
199
+ source="PAC-ID",
200
+ type="Recommendation",
201
+ highlight_pattern=model.issuer,
202
+ highlight_sub=not_recommended_chars,
203
+ msg=f"Characters {' '.join(not_recommended_chars)} should not be used. Issuer SHOULD contain only the characters A-Z, 0-9, -, and . "
204
+ )
205
+ return model
206
+
207
+
208
+ class PACID_With_Extensions(BaseModelWithWarnings):
209
+ pac_id: PACID
210
+ extensions: list[Extension] = Field(default_factory=list)
211
+
212
+
213
+
214
+
215
+
@@ -4,6 +4,8 @@ import re
4
4
  from types import MappingProxyType
5
5
  from .data_model import *
6
6
 
7
+ from ..validation import extract_warnings, ValidationWarning
8
+
7
9
 
8
10
  category_conventions = MappingProxyType(
9
11
  {
@@ -29,7 +31,7 @@ class PAC_Parser():
29
31
  def __init__(self, extension_interpreters:dict[str, Extension]=None):
30
32
  self.extension_interpreters = extension_interpreters or {}
31
33
 
32
- def parse_pac_url(self, pac_url:str) -> PACID_With_Extensions:
34
+ def parse_pac_url(self, pac_url:str) -> tuple[PACID_With_Extensions, list[ValidationWarning] ]:
33
35
  if '*' in pac_url:
34
36
  id_str, ext_str = pac_url.split('*', 1)
35
37
  else:
@@ -37,8 +39,12 @@ class PAC_Parser():
37
39
  ext_str = ""
38
40
 
39
41
  pac_id = self.parse_pac_id(id_str)
40
- extensions = self.parse_extensions(ext_str)
41
- return PACID_With_Extensions(pac_id=pac_id, extensions=extensions)
42
+ extensions = self.parse_extensions(ext_str)
43
+
44
+ pac_with_extension = PACID_With_Extensions(pac_id=pac_id, extensions=extensions)
45
+ warnings = extract_warnings(pac_with_extension)
46
+
47
+ return pac_with_extension, warnings
42
48
 
43
49
 
44
50
  def parse_id_segments(self, identifier:str):
@@ -46,6 +52,8 @@ class PAC_Parser():
46
52
  return []
47
53
 
48
54
  id_segments = list()
55
+ if len(identifier) > 0 and identifier[0] == '/':
56
+ identifier = identifier[1:]
49
57
  for s in identifier.split('/'):
50
58
  tmp = s.split(':')
51
59
 
@@ -85,10 +93,11 @@ class PAC_Parser():
85
93
  default_keys = None
86
94
  id_segments = self.parse_id_segments(d.get('identifier'))
87
95
  id_segments = self._apply_category_defaults(id_segments)
88
-
89
- return PACID(issuer= d.get('issuer'),
96
+
97
+ pac = PACID(issuer= d.get('issuer'),
90
98
  identifier=Identifier(segments=id_segments)
91
99
  )
100
+ return pac
92
101
 
93
102
 
94
103
  def parse_extensions(self, extensions_str:str|None) -> list[Extension]:
@@ -4,7 +4,7 @@ from .data_model import *
4
4
 
5
5
 
6
6
  class PAC_Serializer():
7
- def to_url(self, pac:PACID|PACID_With_Extensions, extensions:list[Extension]=None, use_short_notation_for_extensions=False) -> str:
7
+ def to_url(self, pac:PACID|PACID_With_Extensions, extensions:list[Extension]=None, use_short_notation_for_extensions=False, uppercase_only=False) -> str:
8
8
  if isinstance(pac, PACID_With_Extensions):
9
9
  if extensions:
10
10
  raise ValueError('Extensions were given twice, as part of PACID_With_Extension and as method parameter.')
@@ -13,7 +13,10 @@ class PAC_Serializer():
13
13
  issuer = pac.issuer
14
14
  extensions_str = self._serialize_extensions(extensions, use_short_notation_for_extensions)
15
15
  id_segments = self._serialize_id_segments(pac.identifier.segments)
16
- return f"HTTPS://PAC.{issuer}{id_segments}{extensions_str}".upper()
16
+ out = f"HTTPS://PAC.{issuer}{id_segments}{extensions_str}"
17
+ if uppercase_only:
18
+ out = out.upper()
19
+ return out
17
20
 
18
21
 
19
22
  def _serialize_id_segments(self, segments):
@@ -0,0 +1,16 @@
1
+ from enum import Enum
2
+
3
+
4
+ class WellKnownSegmentKeys(Enum):
5
+ GTIN = '01'
6
+ BATCH = '10'
7
+ SERIAL = '21'
8
+ ADDITIONAL_IDINTIFIER = '240'
9
+ RUN_ID_ABSOLUTE = 'RNR'
10
+ SAMPLE_ID = 'SMP'
11
+ EXPERIMENT_ID = 'EXP'
12
+ RESULT_ID = 'RST'
13
+ METHOD_ID = 'MTD'
14
+ REPORT_ID = 'RPT'
15
+ TIMESTAMP = 'TS'
16
+ VERSION = 'V'
@@ -60,26 +60,35 @@ class PydanticUncertainQuantity(BaseModel):
60
60
 
61
61
  unit_map = [
62
62
  ('MGM', units.milligram),
63
+ ('GRM', units.gram),
64
+ ('KGM', units.kilogram),
65
+
63
66
  ('CEL', units.celsius),
67
+
64
68
  ('LTR', units.liter),
65
69
  ('MLT', units.milliliter),
66
- ('GRM', units.gram),
67
- ('KGM', units.kilogram),
70
+
68
71
  ('C34', units.mole),
69
72
  ('D43',units.atomic_mass_unit),
73
+
70
74
  ('1', units.dimensionless),
71
75
  ('C62', units.dimensionless),
76
+
72
77
  ('BAR',units.bar),
73
78
  ('MBR',units.millibar),
74
79
  ('KBA',units.kilobar),
80
+
75
81
  ('RPM', units.rpm),
76
- ('HUR', units.hour),
82
+
77
83
  ('HTZ', units.hertz),
78
84
  ('KHZ', units.kilohertz),
79
85
  ('MHZ',units.megahertz),
86
+
80
87
  ('SEC', units.second),
81
88
  ('MIN', units.minute),
82
- ('URH', units.hour)
89
+ ('HUR', units.hour),
90
+
91
+ ('MTR', units.meter)
83
92
 
84
93
  ]
85
94
 
@@ -2,4 +2,4 @@
2
2
  Python implementation of LabFREED building blocks
3
3
  '''
4
4
 
5
- __version__ = "0.0.3"
5
+ __version__ = "0.0.5"
@@ -0,0 +1,71 @@
1
+ from pydantic import BaseModel, Field, PrivateAttr
2
+ from typing import List, Set, Tuple
3
+
4
+
5
+ domain_name_pattern = r"(?!-)([A-Za-z0-9-]{1,63}(?<!-)\.)+[A-Za-z]{2,63}"
6
+ hsegment_pattern = r"[A-Za-z0-9_\-\.~!$&'()+,:;=@]|%[0-9A-Fa-f]{2}"
7
+
8
+
9
+ class ValidationWarning(BaseModel):
10
+ source:str
11
+ type: str
12
+ problem_msg:str
13
+ recommendation_msg: str = ""
14
+ highlight:str = "" #this can be used to highlight problematic parts
15
+ highlight_sub:list[str] = Field(default_factory=list())
16
+
17
+
18
+
19
+
20
+ class BaseModelWithWarnings(BaseModel):
21
+ """ Extension of Pydantic BaseModel, so that validator can issue warnings.
22
+ The purpose of that is to allow only minimal validation but on top check for stricter recommendations"""
23
+ _warnings: list[ValidationWarning] = PrivateAttr(default_factory=list)
24
+
25
+ def add_warning(self, *, msg: str, type:str, recommendation:str="", source:str="", highlight_pattern="", highlight_sub=None):
26
+ if not highlight_sub:
27
+ highlight_sub = []
28
+ w = ValidationWarning(problem_msg=msg, recommendation_msg=recommendation, source=source, type=type, highlight=highlight_pattern, highlight_sub=highlight_sub)
29
+ if not w in self._warnings:
30
+ self._warnings.append(w)
31
+
32
+ def get_warnings(self) -> list[ValidationWarning]:
33
+ return self._warnings
34
+
35
+ def clear_warnings(self):
36
+ self._warnings.clear()
37
+
38
+
39
+ # Function to extract warnings from a model and its nested models
40
+ def extract_warnings(model: BaseModelWithWarnings, parent_name: str = "", visited: Set[int] = None) -> List[ValidationWarning]:
41
+ """
42
+ Recursively extract warnings from a Pydantic model and its nested fields.
43
+
44
+ :param model: The Pydantic model instance to inspect.
45
+ :param parent_name: The name of the parent model to track the path.
46
+ :return: List of tuples containing (model name, warning message).
47
+ """
48
+ if visited is None:
49
+ visited = set()
50
+
51
+ model_id = id(model)
52
+ if model_id in visited:
53
+ return []
54
+ visited.add(model_id)
55
+
56
+ warnings_list = [(parent_name or model.__class__.__name__, model_id, warning) for warning in model.get_warnings()]
57
+
58
+ for field_name, field in model.__fields__.items():
59
+ full_path = f"{parent_name}.{field_name}" if parent_name else field_name
60
+ value = getattr(model, field_name)
61
+
62
+ if isinstance(value, BaseModelWithWarnings):
63
+ warnings_list.extend(extract_warnings(value, full_path, visited))
64
+ elif isinstance(value, list):
65
+ for index, item in enumerate(value):
66
+ if isinstance(item, BaseModelWithWarnings):
67
+ list_path = f"{full_path}[{index}]"
68
+ warnings_list.extend(extract_warnings(item, list_path, visited))
69
+
70
+ return warnings_list
71
+
@@ -4,13 +4,13 @@ build-backend = "flit_core.buildapi"
4
4
 
5
5
  [project]
6
6
  name = "labfreed"
7
- authors = [{name = "Reto Thürer", email = "reto.thuerer@gmail.com"}]
7
+ authors = [{name = "Reto Thürer", email = "thuerer.r@buchi.com"}]
8
8
  license = "MIT"
9
9
  license-files = ["LICENSE"]
10
10
  dynamic = ["version", "description"]
11
11
 
12
12
  readme = "README.md"
13
- requires-python = ">=3.12"
13
+ requires-python = ">=3.10"
14
14
  classifiers = [
15
15
  "Programming Language :: Python :: 3",
16
16
  "Operating System :: OS Independent",
@@ -14,20 +14,20 @@ parser = PAC_Parser()
14
14
 
15
15
  # Issuer
16
16
  def test_standard_base_gives_correct_issuer():
17
- pac = parser.parse_pac_url("HTTPS://PAC.METTORIUS.COM/" + valid_standard_segments).pac_id
18
- assert pac.issuer == "METTORIUS.COM"
17
+ pac, _ = parser.parse_pac_url("HTTPS://PAC.METTORIUS.COM/" + valid_standard_segments)
18
+ assert pac.pac_id.issuer == "METTORIUS.COM"
19
19
 
20
20
  def test_pac_can_be_missing_from_domain():
21
- pac = parser.parse_pac_url("HTTPS://METTORIUS.COM/" + valid_standard_segments).pac_id
22
- assert pac.issuer == "METTORIUS.COM"
21
+ pac, _ = parser.parse_pac_url("HTTPS://METTORIUS.COM/" + valid_standard_segments)
22
+ assert pac.pac_id.issuer == "METTORIUS.COM"
23
23
 
24
24
  def test_pac_can_be_missing_from_domain():
25
- pac = parser.parse_pac_url("METTORIUS.COM/" + valid_standard_segments).pac_id
26
- assert pac.issuer == "METTORIUS.COM"
25
+ pac, _ = parser.parse_pac_url("METTORIUS.COM/" + valid_standard_segments)
26
+ assert pac.pac_id.issuer == "METTORIUS.COM"
27
27
 
28
28
  def test_issuer_must_be_valid_domain():
29
29
  with pytest.raises(Exception):
30
- pac = parser.parse_pac_url("HTTPS://METTORIUS/" + valid_standard_segments).pac_id
30
+ pac, _ = parser.parse_pac_url("HTTPS://METTORIUS/" + valid_standard_segments)
31
31
 
32
32
 
33
33
 
@@ -39,19 +39,20 @@ def test_pac_must_have_at_least_one_segment():
39
39
  pac = parser.parse_pac_url(valid_base = "").pac_id
40
40
 
41
41
  def test_identifier_named_segment():
42
- pac = parser.parse_pac_url(valid_base + "KEY:VAL").pac_id
43
- seg: IDSegment = pac.identifier.segments[0]
42
+ pac, _ = parser.parse_pac_url(valid_base + "KEY:VAL")
43
+ seg: IDSegment = pac.pac_id.identifier.segments[0]
44
44
  assert seg.key == "KEY"
45
45
  assert seg.value == "VAL"
46
46
 
47
47
  def test_identifier_unnamed_segment():
48
- pac = parser.parse_pac_url(valid_base + "VAL").pac_id
49
- seg: IDSegment = pac.identifier.segments[0]
48
+ pac, _ = parser.parse_pac_url(valid_base + "VAL")
49
+ seg: IDSegment = pac.pac_id.identifier.segments[0]
50
50
  assert not seg.key
51
51
  assert seg.value == "VAL"
52
52
 
53
53
  def test_identifier_combination_of_named_and_unnamed_segments():
54
- pac = parser.parse_pac_url(valid_base + "KEY0:VAL0/VAL1/KEY2:VAL2").pac_id
54
+ pac, _ = parser.parse_pac_url(valid_base + "KEY0:VAL0/VAL1/KEY2:VAL2")
55
+ pac = pac.pac_id
55
56
  seg: IDSegment = pac.identifier.segments[0]
56
57
  assert seg.key == 'KEY0'
57
58
  assert seg.value == "VAL0"
@@ -66,10 +67,10 @@ def test_identifier_combination_of_named_and_unnamed_segments():
66
67
 
67
68
  def test_keys_must_be_unique():
68
69
  with pytest.raises(Exception):
69
- pac = parser.parse_pac_url(valid_base + "KEY:VAL/KEY:ANOTHERVAL/KEY:VAL/KEY2:ANOTHERVAL").pac_id
70
+ pac, _ = parser.parse_pac_url(valid_base + "KEY:VAL/KEY:ANOTHERVAL/KEY:VAL/KEY2:ANOTHERVAL")
70
71
 
71
72
  def test_keys_can_repeat_accross_categories():
72
- pac = parser.parse_pac_url(valid_base + "-MD/KEY:VAL/-MS/KEY:VAL").pac_id
73
+ pac, _ = parser.parse_pac_url(valid_base + "-MD/KEY:VAL/-MS/KEY:VAL")
73
74
  assert True # made it here without exception > it's fine
74
75
 
75
76
 
@@ -77,28 +78,28 @@ def test_keys_can_repeat_accross_categories():
77
78
 
78
79
  # Identifier Segments Categories (Recommendation)
79
80
  def test_basic_valid_category():
80
- pac = parser.parse_pac_url(valid_base + "-MD/KEY:VAL").pac_id
81
- cat: Category = pac.identifier.categories[0]
81
+ pac, _ = parser.parse_pac_url(valid_base + "-MD/KEY:VAL")
82
+ cat: Category = pac.pac_id.identifier.categories[0]
82
83
  assert cat.key == '-MD'
83
84
  assert cat.segments[0].key == 'KEY'
84
85
  assert cat.segments[0].value == 'VAL'
85
86
 
86
87
  def test_if_no_category_is_specified_default_to_unnamed_category():
87
- pac = parser.parse_pac_url(valid_base + "KEY:VAL").pac_id
88
- cat: Category = pac.identifier.categories[0]
88
+ pac, _ = parser.parse_pac_url(valid_base + "KEY:VAL")
89
+ cat: Category = pac.pac_id.identifier.categories[0]
89
90
  assert cat.key == None
90
91
  assert cat.segments[0].key == 'KEY'
91
92
  assert cat.segments[0].value == 'VAL'
92
93
 
93
94
  def test_missing_dash_in_category_name_is_interpreted_as_unnamed_segment():
94
- pac = parser.parse_pac_url(valid_base + "MD/KEY:VAL").pac_id
95
- cat: Category = pac.identifier.categories[0]
95
+ pac, _ = parser.parse_pac_url(valid_base + "MD/KEY:VAL")
96
+ cat: Category = pac.pac_id.identifier.categories[0]
96
97
  assert cat.key == None
97
98
  assert cat.segments[0].value == 'MD'
98
99
 
99
100
  def test_category_with_multiple_segments():
100
- pac = parser.parse_pac_url(valid_base + "-MD/KEY0:VAL0/VAL1/KEY2:VAL2").pac_id
101
- cat: Category = pac.identifier.categories[0]
101
+ pac, _ = parser.parse_pac_url(valid_base + "-MD/KEY0:VAL0/VAL1/KEY2:VAL2")
102
+ cat: Category = pac.pac_id.identifier.categories[0]
102
103
  assert cat.key == '-MD'
103
104
 
104
105
  seg: IDSegment = cat.segments[0]
@@ -114,19 +115,20 @@ def test_category_with_multiple_segments():
114
115
  assert seg.value == "VAL2"
115
116
 
116
117
  def test_two_categories():
117
- pac = parser.parse_pac_url(valid_base + "-DR/KEY:VAL/-MD/KEY:VAL").pac_id
118
- cat: Category = pac.identifier.categories[0]
118
+ pac, _ = parser.parse_pac_url(valid_base + "-DR/KEY:VAL/-MD/KEY:VAL")
119
+ cat: Category = pac.pac_id.identifier.categories[0]
119
120
  assert cat.key == '-DR'
120
121
  assert cat.segments[0].key == 'KEY'
121
122
  assert cat.segments[0].value == 'VAL'
122
123
 
123
- cat: Category = pac.identifier.categories[1]
124
+ cat: Category = pac.pac_id.identifier.categories[1]
124
125
  assert cat.key == '-MD'
125
126
  assert cat.segments[0].key == 'KEY'
126
127
  assert cat.segments[0].value == 'VAL'
127
128
 
128
129
  def test_three_categories():
129
- pac = parser.parse_pac_url(valid_base + "-DR/KEY0:VAL0/-MD/KEY1:VAL1/-CAT/KEY2:VAL2").pac_id
130
+ pac, _ = parser.parse_pac_url(valid_base + "-DR/KEY0:VAL0/-MD/KEY1:VAL1/-CAT/KEY2:VAL2")
131
+ pac = pac.pac_id
130
132
  cat: Category = pac.identifier.categories[0]
131
133
  assert cat.key == '-DR'
132
134
  assert cat.segments[0].key == 'KEY0'
@@ -143,14 +145,14 @@ def test_three_categories():
143
145
  assert cat.segments[0].value == 'VAL2'
144
146
 
145
147
  def test_implied_segments_of_MD_category():
146
- pac = parser.parse_pac_url(valid_base + "-MD/0/1").pac_id
147
- cat: Category = pac.identifier.categories[0]
148
+ pac, _ = parser.parse_pac_url(valid_base + "-MD/0/1")
149
+ cat: Category = pac.pac_id.identifier.categories[0]
148
150
  assert cat.segments[0].key == '240'
149
151
  assert cat.segments[1].key == '21'
150
152
 
151
153
  def test_implied_segments_of_MS_category():
152
- pac = parser.parse_pac_url(valid_base + "-MS/0/1/2/3/4").pac_id
153
- cat: Category = pac.identifier.categories[0]
154
+ pac, _ = parser.parse_pac_url(valid_base + "-MS/0/1/2/3/4")
155
+ cat: Category = pac.pac_id.identifier.categories[0]
154
156
  assert cat.segments[0].key == '240'
155
157
  assert cat.segments[1].key == '10'
156
158
  assert cat.segments[2].key == '20'
@@ -158,8 +160,8 @@ def test_implied_segments_of_MS_category():
158
160
  assert cat.segments[4].key == '250'
159
161
 
160
162
  def test_implied_segments_of_MC_category():
161
- pac = parser.parse_pac_url(valid_base + "-MC/0/1/2/3/4").pac_id
162
- cat: Category = pac.identifier.categories[0]
163
+ pac, _ = parser.parse_pac_url(valid_base + "-MC/0/1/2/3/4")
164
+ cat: Category = pac.pac_id.identifier.categories[0]
163
165
  assert cat.segments[0].key == '240'
164
166
  assert cat.segments[1].key == '10'
165
167
  assert cat.segments[2].key == '20'
@@ -167,8 +169,8 @@ def test_implied_segments_of_MC_category():
167
169
  assert cat.segments[4].key == '250'
168
170
 
169
171
  def test_implied_segments_of_MM_category():
170
- pac = parser.parse_pac_url(valid_base + "-MM/0/1/2/3/4").pac_id
171
- cat: Category = pac.identifier.categories[0]
172
+ pac, _ = parser.parse_pac_url(valid_base + "-MM/0/1/2/3/4")
173
+ cat: Category = pac.pac_id.identifier.categories[0]
172
174
  assert cat.segments[0].key == '240'
173
175
  assert cat.segments[1].key == '10'
174
176
  assert cat.segments[2].key == '20'
@@ -176,8 +178,8 @@ def test_implied_segments_of_MM_category():
176
178
  assert cat.segments[4].key == '250'
177
179
 
178
180
  def test_stop_implying_segments_after_an_explicit_one_is_found():
179
- pac = parser.parse_pac_url(valid_base + "-MS/0/1/KEY:2/3/4").pac_id
180
- cat: Category = pac.identifier.categories[0]
181
+ pac, _ = parser.parse_pac_url(valid_base + "-MS/0/1/KEY:2/3/4")
182
+ cat: Category = pac.pac_id.identifier.categories[0]
181
183
  assert cat.segments[0].key == '240'
182
184
  assert cat.segments[1].key == '10'
183
185
  assert cat.segments[2].key == 'KEY'
@@ -185,15 +187,16 @@ def test_stop_implying_segments_after_an_explicit_one_is_found():
185
187
  assert cat.segments[4].key == None
186
188
 
187
189
  def test_more_segments_than_implicit_keys():
188
- pac = parser.parse_pac_url(valid_base + "-MD/IMPLIED1/IMPLIED2/ADDITIONAL").pac_id
189
- cat: Category = pac.identifier.categories[0]
190
+ pac, _ = parser.parse_pac_url(valid_base + "-MD/IMPLIED1/IMPLIED2/ADDITIONAL")
191
+ cat: Category = pac.pac_id.identifier.categories[0]
190
192
  assert cat.segments[2].key == None
191
193
 
192
194
 
193
195
 
194
196
  # Extensions
195
197
  def test_valid_extensions():
196
- extensions = parser.parse_pac_url(valid_base + valid_standard_segments + "*name1$t1/data1*name2$t2/data2").extensions
198
+ pac, _ = parser.parse_pac_url(valid_base + valid_standard_segments + "*name1$t1/data1*name2$t2/data2")
199
+ extensions = pac.extensions
197
200
  ext: Extension = extensions[0]
198
201
  assert ext.name == 'name1'
199
202
  assert ext.type == 't1'
@@ -226,7 +229,8 @@ def test_known_extension_types_are_parsed():
226
229
  'KNOWN_EXTENSION': ExtensionMockType,
227
230
  }
228
231
  parser_with_known_extension = PAC_Parser(extension_interpreters)
229
- extensions = parser_with_known_extension.parse_pac_url(valid_base + valid_standard_segments + "*name1$KNOWN_EXTENSION/data1").extensions
232
+ pac, _ = parser_with_known_extension.parse_pac_url(valid_base + valid_standard_segments + "*name1$KNOWN_EXTENSION/data1")
233
+ extensions= pac.extensions
230
234
  ext: Extension = extensions[0]
231
235
  assert isinstance(ext, ExtensionMockType)
232
236
  assert ext.name == 'name__foo'
@@ -235,7 +239,8 @@ def test_known_extension_types_are_parsed():
235
239
 
236
240
 
237
241
  def test_imply_display_name_and_summary_extension():
238
- extensions = parser.parse_pac_url(valid_base + valid_standard_segments + "*data1*data2").extensions
242
+ pac, _ = parser.parse_pac_url(valid_base + valid_standard_segments + "*data1*data2")
243
+ extensions = pac.extensions
239
244
  ext: Extension = extensions[0]
240
245
  assert ext.name == 'N'
241
246
  assert ext.type == 'N'
@@ -246,7 +251,8 @@ def test_imply_display_name_and_summary_extension():
246
251
 
247
252
  def test_stop_imply_extensions_after_explicit():
248
253
  with pytest.raises(Exception):
249
- _ = parser.parse_pac_url(valid_base + valid_standard_segments + "*N$T/data1*data2").extensions
254
+ pac, _ = parser.parse_pac_url(valid_base + valid_standard_segments + "*N$T/data1*data2")
255
+ pac.extensions
250
256
 
251
257
 
252
258
  def test_extension_parsing():
@@ -19,13 +19,29 @@ pac_id = PACID(issuer = 'mettorius.com',
19
19
  ])
20
20
  )
21
21
  extensions= [
22
+ UnknownExtension(name_= 'N', type_= 'T.T', data_= 'DATA'),
23
+ UnknownExtension(name_= 'SUM', type_= 'TREX', data_= 'DATA'),
22
24
  UnknownExtension(name_= 'FOO', type_= 'T', data_= 'DATA'),
23
25
  UnknownExtension(name_= 'BAR', type_= 'T2', data_= 'DATA')
24
26
  ]
25
27
 
26
28
 
27
29
  def test_url_serialization():
28
- url = serializer.to_url(pac_id, extensions)
30
+ url = serializer.to_url(pac_id, extensions, use_short_notation_for_extensions=False, uppercase_only=True)
31
+ assert url == 'HTTPS://PAC.METTORIUS.COM/-DR/999/-MD/240:1/21:2*N$T.T/DATA*SUM$TREX/DATA*FOO$T/DATA*BAR$T2/DATA'
32
+
33
+ def test_url_serialization_short_notation_for_N_and_SUM_extensions():
34
+ url = serializer.to_url(pac_id, extensions, use_short_notation_for_extensions=True, uppercase_only=True)
35
+ assert url == 'HTTPS://PAC.METTORIUS.COM/-DR/999/-MD/240:1/21:2*DATA*DATA*FOO$T/DATA*BAR$T2/DATA'
36
+
37
+ def test_url_serialization_short_notation_for_N_extensions():
38
+ ext = [extensions[i] for i in (0,2,3)]
39
+ url = serializer.to_url(pac_id, ext, use_short_notation_for_extensions=True, uppercase_only=True)
40
+ assert url == 'HTTPS://PAC.METTORIUS.COM/-DR/999/-MD/240:1/21:2*DATA*FOO$T/DATA*BAR$T2/DATA'
41
+
42
+ def test_url_serialization_short_notation_stop_implying_when_specific_extension():
43
+ ext = [extensions[i] for i in (2,3)]
44
+ url = serializer.to_url(pac_id, ext, use_short_notation_for_extensions=True, uppercase_only=True)
29
45
  assert url == 'HTTPS://PAC.METTORIUS.COM/-DR/999/-MD/240:1/21:2*FOO$T/DATA*BAR$T2/DATA'
30
46
 
31
47
 
labfreed-0.0.3/README.md DELETED
@@ -1,3 +0,0 @@
1
- # LabFREED for Python
2
-
3
- A python implementation of [LabFREED](www.labfreed.com).
@@ -1,114 +0,0 @@
1
- from typing import Optional
2
- from typing_extensions import Self
3
- from pydantic import BaseModel, Field, computed_field, conlist, model_validator
4
- from abc import ABC, abstractproperty, abstractstaticmethod
5
-
6
- class IDSegment(BaseModel):
7
- key:Optional[str] = Field(None, pattern=r'^[A-Z0-9-+]+$', min_length=1)
8
- value:str = Field(..., pattern=r'^[A-Z0-9-+]+$', min_length=1)
9
-
10
-
11
- class Category(BaseModel):
12
- key:str|None = None
13
- segments: list[IDSegment]
14
-
15
-
16
- class Identifier(BaseModel):
17
- segments: conlist(IDSegment, min_length=1) = Field(..., exclude=True) # exclude=True prevents this from being serialized by Pydantic
18
-
19
- @computed_field
20
- @property
21
- def categories(self) -> list[Category]:
22
- categories = list()
23
- c = Category(segments=[])
24
- categories.append(c)
25
- for s in self.segments:
26
- # new category starts with "-"
27
- if s.value[0] == '-':
28
- cat_key = s.value
29
- c = Category(key=cat_key, segments=[])
30
- categories.append(c)
31
- else:
32
- c.segments.append(s)
33
-
34
- # the first category might have no segments. remove categories without segments
35
- if not categories[0].segments:
36
- categories = categories[1:]
37
-
38
- return categories
39
-
40
- @model_validator(mode='after')
41
- def check_keys_are_unique_in_each_category(self) -> Self:
42
- for c in self.categories:
43
- keys = [s.key for s in c.segments if s.key]
44
- duplicate_keys = [k for k in set(keys) if keys.count(k) > 1]
45
- if duplicate_keys:
46
- raise ValueError(f'Duplicate keys {",".join(duplicate_keys)} in category {c.key}')
47
- return self
48
-
49
- @staticmethod
50
- def from_categories(categories:list[Category]) :
51
- segments = list()
52
- for c in categories:
53
- if c.key:
54
- segments.append(IDSegment(value=c.key))
55
- segments.extend(c.segments)
56
- return Identifier(segments=segments)
57
-
58
-
59
-
60
- class Extension(ABC, BaseModel):
61
-
62
- @abstractproperty
63
- def name(self)->str:
64
- pass
65
-
66
- @abstractproperty
67
- def type(self)->str:
68
- pass
69
-
70
- @abstractproperty
71
- def data(self)->str:
72
- pass
73
-
74
- @abstractstaticmethod
75
- def from_spec_fields(name, type, data):
76
- pass
77
-
78
-
79
- class UnknownExtension(Extension):
80
- name_:str
81
- type_:str
82
- data_:str
83
-
84
- @property
85
- def name(self)->str:
86
- return self.name_
87
-
88
- @property
89
- def type(self)->str:
90
- return self.type_
91
-
92
- @property
93
- def data(self)->str:
94
- return self.data_
95
-
96
- @staticmethod
97
- def from_spec_fields(name, type, data):
98
- return UnknownExtension(name_=name, type_=type, data_=data)
99
-
100
-
101
-
102
- class PACID(BaseModel):
103
- issuer:str
104
- identifier: Identifier
105
-
106
-
107
- class PACID_With_Extensions(BaseModel):
108
- pac_id: PACID
109
- extensions: list[Extension] = Field(default_factory=list)
110
-
111
-
112
-
113
-
114
-
File without changes
File without changes
File without changes
File without changes
File without changes