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

68 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-02 14:21 +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, ImportantFileSource, PermissiveFileSource, RelativeFilePath 

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

15from .license_id import DeprecatedLicenseId, LicenseId 

16from .url import HttpUrl 

17from .validated_string import ValidatedString 

18from .validator_annotations import AfterValidator, BeforeValidator 

19from .version_type import Version 

20 

21UTC = timezone.utc 

22 

23__all__ = [ 

24 "AbsoluteDirectory", 

25 "AbsoluteFilePath", 

26 "Datetime", 

27 "DeprecatedLicenseId", 

28 "Doi", 

29 "FileName", 

30 "FilePath", 

31 "FileSource", 

32 "HttpUrl", 

33 "Identifier", 

34 "ImportantFileSource", 

35 "LicenseId", 

36 "LowerCaseIdentifier", 

37 "NotEmpty", 

38 "OrcidId", 

39 "PermissiveFileSource", 

40 "RelativeFilePath", 

41 "Sha256", 

42 "SiUnit", 

43 "Version", 

44] 

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

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

47 

48 

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

50 if not s.isidentifier(): 

51 raise ValueError( 

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

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

54 + " for details." 

55 ) 

56 

57 return s 

58 

59 

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

61 if iskeyword(s): 

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

63 

64 return s 

65 

66 

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

68 if isinstance(dt, datetime): 

69 return dt 

70 elif isinstance(dt, str): 

71 return isoparse(dt).astimezone(UTC) 

72 

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

74 

75 

76def _validate_orcid_id(orcid_id: str): 

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

78 check = 0 

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

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

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

82 if check == 1: 

83 return orcid_id # valid 

84 

85 raise ValueError( 

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

87 ) 

88 

89 

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

91# to remove _serialize_datetime 

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

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

94 

95 

96class Datetime( 

97 RootModel[ 

98 Annotated[ 

99 datetime, 

100 BeforeValidator(_validate_datetime), 

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

102 ] 

103 ] 

104): 

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

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

107 """ 

108 

109 @classmethod 

110 def now(cls): 

111 return cls(datetime.now(UTC)) 

112 

113 

114class Doi(ValidatedString): 

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

116 

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

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

119 ] 

120 

121 

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

123IdentifierAnno = Annotated[ 

124 NotEmpty[str], 

125 AfterValidator(_validate_identifier), 

126 AfterValidator(_validate_is_not_keyword), 

127] 

128 

129 

130class Identifier(ValidatedString): 

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

132 

133 

134LowerCaseIdentifierAnno = Annotated[IdentifierAnno, annotated_types.LowerCase] 

135 

136 

137class LowerCaseIdentifier(ValidatedString): 

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

139 

140 

141class OrcidId(ValidatedString): 

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

143 

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

145 Annotated[str, AfterValidator(_validate_orcid_id)] 

146 ] 

147 

148 

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

150 if isinstance(si_unit, str): 

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

152 else: 

153 return si_unit 

154 

155 

156class SiUnit(ValidatedString): 

157 """An SI unit""" 

158 

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

160 Annotated[ 

161 str, 

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

163 BeforeValidator(_normalize_multiplication), 

164 ] 

165 ] 

166 

167 

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

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

170MismatchedElementsPerMillion = Annotated[int, annotated_types.Interval(ge=0, le=100)]