Coverage for bioimageio/spec/_internal/types.py: 96%

80 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-12 17:44 +0000

1from __future__ import annotations 

2 

3from datetime import datetime, timezone 

4from keyword import iskeyword 

5from typing import Any, ClassVar, Sequence, Type, TypeVar, Union 

6 

7import annotated_types 

8from dateutil.parser import isoparse 

9from pydantic import PlainSerializer, RootModel, StringConstraints 

10from typing_extensions import Annotated, Literal 

11 

12from .constants import DOI_REGEX, SI_UNIT_REGEX 

13from .field_warning import AfterWarner 

14from .io import FileSource, PermissiveFileSource, RelativeFilePath 

15from .io_basics import AbsoluteDirectory, AbsoluteFilePath, FileName, FilePath, Sha256 

16from .io_packaging import FileSource_ 

17from .license_id import DeprecatedLicenseId, LicenseId 

18from .type_guards import is_sequence 

19from .url import HttpUrl 

20from .validated_string import ValidatedString 

21from .validator_annotations import AfterValidator, BeforeValidator 

22from .version_type import Version 

23from .warning_levels import ALERT 

24 

25UTC = timezone.utc 

26 

27__all__ = [ 

28 "AbsoluteDirectory", 

29 "AbsoluteFilePath", 

30 "Datetime", 

31 "DeprecatedLicenseId", 

32 "Doi", 

33 "FileName", 

34 "FilePath", 

35 "FileSource", 

36 "FileSource_", 

37 "ImportantFileSource", 

38 "HttpUrl", 

39 "Identifier", 

40 "LicenseId", 

41 "LowerCaseIdentifier", 

42 "NotEmpty", 

43 "OrcidId", 

44 "PermissiveFileSource", 

45 "RelativeFilePath", 

46 "Sha256", 

47 "SiUnit", 

48 "Version", 

49] 

50S = TypeVar("S", bound=Sequence[Any]) 

51A = TypeVar("A", bound=Any) 

52NotEmpty = Annotated[S, annotated_types.MinLen(1)] 

53 

54 

55def _validate_fair(value: Any) -> Any: 

56 """Raise trivial values.""" 

57 if value is None or (is_sequence(value) and not value): 

58 raise ValueError("Needs to be filled for FAIR compliance") 

59 

60 return value 

61 

62 

63FAIR = Annotated[ 

64 A, 

65 AfterWarner(_validate_fair, severity=ALERT), 

66] 

67 

68ImportantFileSource = FileSource_ 

69"""DEPRECATED alias, use `FileSource` instead""" 

70 

71 

72def _validate_identifier(s: str) -> str: 

73 if not s.isidentifier(): 

74 raise ValueError( 

75 f"'{s}' is not a valid (Python) identifier, see" 

76 + " https://docs.python.org/3/reference/lexical_analysis.html#identifiers" 

77 + " for details." 

78 ) 

79 

80 return s 

81 

82 

83def _validate_is_not_keyword(s: str) -> str: 

84 if iskeyword(s): 

85 raise ValueError(f"'{s}' is a Python keyword and not allowed here.") 

86 

87 return s 

88 

89 

90def _validate_datetime(dt: Union[datetime, str, Any]) -> datetime: 

91 if isinstance(dt, datetime): 

92 return dt 

93 elif isinstance(dt, str): 

94 return isoparse(dt).astimezone(UTC) 

95 

96 raise ValueError(f"'{dt}' not a string or datetime.") 

97 

98 

99def _validate_orcid_id(orcid_id: str): 

100 if len(orcid_id) == 19 and all(orcid_id[idx] == "-" for idx in [4, 9, 14]): 

101 check = 0 

102 for n in orcid_id[:4] + orcid_id[5:9] + orcid_id[10:14] + orcid_id[15:]: 

103 # adapted from stdnum.iso7064.mod_11_2.checksum() 

104 check = (2 * check + int(10 if n == "X" else n)) % 11 

105 if check == 1: 

106 return orcid_id # valid 

107 

108 raise ValueError( 

109 f"'{orcid_id} is not a valid ORCID iD in hyphenated groups of 4 digits." 

110 ) 

111 

112 

113# TODO follow up on https://github.com/pydantic/pydantic/issues/8964 

114# to remove _serialize_datetime 

115def _serialize_datetime_json(dt: datetime) -> str: 

116 return dt.astimezone(UTC).isoformat(timespec="seconds") 

117 

118 

119class Datetime( 

120 RootModel[ 

121 Annotated[ 

122 datetime, 

123 BeforeValidator(_validate_datetime), 

124 PlainSerializer(_serialize_datetime_json, when_used="json-unless-none"), 

125 ] 

126 ] 

127): 

128 """Timestamp in [ISO 8601](#https://en.wikipedia.org/wiki/ISO_8601) format 

129 with a few restrictions listed [here](https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat). 

130 """ 

131 

132 @classmethod 

133 def now(cls): 

134 return cls(datetime.now(UTC)) 

135 

136 

137class Doi(ValidatedString): 

138 """A digital object identifier, see https://www.doi.org/""" 

139 

140 root_model: ClassVar[Type[RootModel[Any]]] = RootModel[ 

141 Annotated[str, StringConstraints(pattern=DOI_REGEX)] 

142 ] 

143 

144 

145FormatVersionPlaceholder = Literal["latest", "discover"] 

146IdentifierAnno = Annotated[ 

147 NotEmpty[str], 

148 AfterValidator(_validate_identifier), 

149 AfterValidator(_validate_is_not_keyword), 

150] 

151 

152 

153class Identifier(ValidatedString): 

154 root_model: ClassVar[Type[RootModel[Any]]] = RootModel[IdentifierAnno] 

155 

156 

157LowerCaseIdentifierAnno = Annotated[IdentifierAnno, annotated_types.LowerCase] 

158 

159 

160class LowerCaseIdentifier(ValidatedString): 

161 root_model: ClassVar[Type[RootModel[Any]]] = RootModel[LowerCaseIdentifierAnno] 

162 

163 

164class OrcidId(ValidatedString): 

165 """An ORCID identifier, see https://orcid.org/""" 

166 

167 root_model: ClassVar[Type[RootModel[Any]]] = RootModel[ 

168 Annotated[str, AfterValidator(_validate_orcid_id)] 

169 ] 

170 

171 

172def _normalize_multiplication(si_unit: Union[Any, str]) -> Union[Any, str]: 

173 if isinstance(si_unit, str): 

174 return si_unit.replace("×", "·").replace("*", "·").replace(" ", "·") 

175 else: 

176 return si_unit 

177 

178 

179class SiUnit(ValidatedString): 

180 """An SI unit""" 

181 

182 root_model: ClassVar[Type[RootModel[Any]]] = RootModel[ 

183 Annotated[ 

184 str, 

185 StringConstraints(min_length=1, pattern=SI_UNIT_REGEX), 

186 BeforeValidator(_normalize_multiplication), 

187 ] 

188 ] 

189 

190 

191RelativeTolerance = Annotated[float, annotated_types.Interval(ge=0, le=1e-2)] 

192AbsoluteTolerance = Annotated[float, annotated_types.Interval(ge=0)] 

193MismatchedElementsPerMillion = Annotated[int, annotated_types.Interval(ge=0, le=1000)]