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

71 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-18 12:47 +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 .io import FileSource, PermissiveFileSource, RelativeFilePath 

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

15from .io_packaging import FileSource_ 

16from .license_id import DeprecatedLicenseId, LicenseId 

17from .url import HttpUrl 

18from .validated_string import ValidatedString 

19from .validator_annotations import AfterValidator, BeforeValidator 

20from .version_type import Version 

21 

22UTC = timezone.utc 

23 

24__all__ = [ 

25 "AbsoluteDirectory", 

26 "AbsoluteFilePath", 

27 "Datetime", 

28 "DeprecatedLicenseId", 

29 "Doi", 

30 "FileName", 

31 "FilePath", 

32 "FileSource", 

33 "FileSource_", 

34 "ImportantFileSource", 

35 "HttpUrl", 

36 "Identifier", 

37 "LicenseId", 

38 "LowerCaseIdentifier", 

39 "NotEmpty", 

40 "OrcidId", 

41 "PermissiveFileSource", 

42 "RelativeFilePath", 

43 "Sha256", 

44 "SiUnit", 

45 "Version", 

46] 

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

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

49 

50ImportantFileSource = FileSource_ 

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

52 

53 

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

55 if not s.isidentifier(): 

56 raise ValueError( 

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

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

59 + " for details." 

60 ) 

61 

62 return s 

63 

64 

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

66 if iskeyword(s): 

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

68 

69 return s 

70 

71 

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

73 if isinstance(dt, datetime): 

74 return dt 

75 elif isinstance(dt, str): 

76 return isoparse(dt).astimezone(UTC) 

77 

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

79 

80 

81def _validate_orcid_id(orcid_id: str): 

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

83 check = 0 

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

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

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

87 if check == 1: 

88 return orcid_id # valid 

89 

90 raise ValueError( 

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

92 ) 

93 

94 

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

96# to remove _serialize_datetime 

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

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

99 

100 

101class Datetime( 

102 RootModel[ 

103 Annotated[ 

104 datetime, 

105 BeforeValidator(_validate_datetime), 

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

107 ] 

108 ] 

109): 

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

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

112 """ 

113 

114 @classmethod 

115 def now(cls): 

116 return cls(datetime.now(UTC)) 

117 

118 

119class Doi(ValidatedString): 

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

121 

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

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

124 ] 

125 

126 

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

128IdentifierAnno = Annotated[ 

129 NotEmpty[str], 

130 AfterValidator(_validate_identifier), 

131 AfterValidator(_validate_is_not_keyword), 

132] 

133 

134 

135class Identifier(ValidatedString): 

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

137 

138 

139LowerCaseIdentifierAnno = Annotated[IdentifierAnno, annotated_types.LowerCase] 

140 

141 

142class LowerCaseIdentifier(ValidatedString): 

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

144 

145 

146class OrcidId(ValidatedString): 

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

148 

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

150 Annotated[str, AfterValidator(_validate_orcid_id)] 

151 ] 

152 

153 

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

155 if isinstance(si_unit, str): 

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

157 else: 

158 return si_unit 

159 

160 

161class SiUnit(ValidatedString): 

162 """An SI unit""" 

163 

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

165 Annotated[ 

166 str, 

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

168 BeforeValidator(_normalize_multiplication), 

169 ] 

170 ] 

171 

172 

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

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

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