Coverage for python/lsst/daf/butler/registry/interfaces/_versioning.py: 44%

65 statements  

« prev     ^ index     » next       coverage.py v7.4.3, created at 2024-03-12 10:07 +0000

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <http://www.gnu.org/licenses/>. 

27 

28from __future__ import annotations 

29 

30__all__ = [ 

31 "IncompatibleVersionError", 

32 "VersionTuple", 

33 "VersionedExtension", 

34] 

35 

36from abc import ABC, abstractmethod 

37from typing import NamedTuple 

38 

39 

40class IncompatibleVersionError(RuntimeError): 

41 """Exception raised when extension implemention is not compatible with 

42 schema version defined in database. 

43 """ 

44 

45 pass 

46 

47 

48class VersionTuple(NamedTuple): 

49 """Class representing a version number. 

50 

51 Attributes 

52 ---------- 

53 major : `int 

54 Major version number. 

55 minor : `int` 

56 Minor version number. 

57 patch : `int` 

58 Patch level. 

59 """ 

60 

61 major: int 

62 minor: int 

63 patch: int 

64 

65 @classmethod 

66 def fromString(cls, versionStr: str) -> VersionTuple: 

67 """Extract version number from a string. 

68 

69 Parameters 

70 ---------- 

71 versionStr : `str` 

72 Version number in string form "X.Y.Z", all components must be 

73 present. 

74 

75 Returns 

76 ------- 

77 version : `VersionTuple` 

78 Parsed version tuple. 

79 

80 Raises 

81 ------ 

82 ValueError 

83 Raised if string has an invalid format. 

84 """ 

85 try: 

86 version = tuple(int(v) for v in versionStr.split(".")) 

87 except ValueError as exc: 

88 raise ValueError(f"Invalid version string '{versionStr}'") from exc 

89 if len(version) != 3: 

90 raise ValueError(f"Invalid version string '{versionStr}', must consist of three numbers") 

91 return cls(*version) 

92 

93 def checkCompatibility(self, registry_schema_version: VersionTuple, update: bool) -> bool: 

94 """Compare implementation schema version with schema version in 

95 registry. 

96 

97 Parameters 

98 ---------- 

99 registry_schema_version : `VersionTuple` 

100 Schema version that exists in registry or defined in a 

101 configuration for a registry to be created. 

102 update : `bool` 

103 If True then read-write access is expected. 

104 

105 Returns 

106 ------- 

107 compatible : `bool` 

108 True if schema versions are compatible. 

109 

110 Notes 

111 ----- 

112 This method implements default rules for checking schema compatibility: 

113 

114 - if major numbers differ, schemas are not compatible; 

115 - otherwise, if minor versions are different then newer version can 

116 read schema made by older version, but cannot write into it; 

117 older version can neither read nor write into newer schema; 

118 - otherwise, different patch versions are totally compatible. 

119 

120 Extensions that implement different versioning model will need to 

121 override their `VersionedExtension.checkCompatibility` method. 

122 """ 

123 if self.major != registry_schema_version.major: 

124 # different major versions are not compatible at all 

125 return False 

126 if self.minor != registry_schema_version.minor: 

127 # different minor versions are backward compatible for read 

128 # access only 

129 return self.minor > registry_schema_version.minor and not update 

130 # patch difference does not matter 

131 return True 

132 

133 def __str__(self) -> str: 

134 """Transform version tuple into a canonical string form.""" 

135 return f"{self.major}.{self.minor}.{self.patch}" 

136 

137 

138class VersionedExtension(ABC): 

139 """Interface for extension classes with versions. 

140 

141 Parameters 

142 ---------- 

143 registry_schema_version : `VersionTuple` or `None` 

144 Schema version of this extension as defined in registry. If `None`, it 

145 means that registry schema was not initialized yet and the extension 

146 should expect that schema version returned by `newSchemaVersion` method 

147 will be used to initialize the registry database. If not `None`, it 

148 is guaranteed that this version has passed compatibility check. 

149 """ 

150 

151 def __init__(self, *, registry_schema_version: VersionTuple | None = None): 

152 self._registry_schema_version = registry_schema_version 

153 

154 @classmethod 

155 def extensionName(cls) -> str: 

156 """Return full name of the extension. 

157 

158 This name should match the name defined in registry configuration. It 

159 is also stored in registry attributes. Default implementation returns 

160 full class name. 

161 

162 Returns 

163 ------- 

164 name : `str` 

165 Full extension name. 

166 """ 

167 return f"{cls.__module__}.{cls.__name__}" 

168 

169 @classmethod 

170 @abstractmethod 

171 def currentVersions(cls) -> list[VersionTuple]: 

172 """Return schema version(s) supported by this extension class. 

173 

174 Returns 

175 ------- 

176 version : `list` [`VersionTuple`] 

177 Schema versions for this extension. Empty list is returned if an 

178 extension does not require its version to be saved or checked. 

179 """ 

180 raise NotImplementedError() 

181 

182 def newSchemaVersion(self) -> VersionTuple | None: 

183 """Return schema version for newly created registry. 

184 

185 Returns 

186 ------- 

187 version : `VersionTuple` or `None` 

188 Schema version created by this extension. `None` is returned if an 

189 extension does not require its version to be saved or checked. 

190 

191 Notes 

192 ----- 

193 Extension classes that support multiple schema versions need to 

194 override `_newDefaultSchemaVersion` method. 

195 """ 

196 return self.clsNewSchemaVersion(self._registry_schema_version) 

197 

198 @classmethod 

199 def clsNewSchemaVersion(cls, schema_version: VersionTuple | None) -> VersionTuple | None: 

200 """Class method which returns schema version to use for newly created 

201 registry database. 

202 

203 Parameters 

204 ---------- 

205 schema_version : `VersionTuple` or `None` 

206 Configured schema version or `None` if default schema version 

207 should be created. If not `None` then it is guaranteed to be 

208 compatible with `currentVersions`. 

209 

210 Returns 

211 ------- 

212 version : `VersionTuple` or `None` 

213 Schema version created by this extension. `None` is returned if an 

214 extension does not require its version to be saved or checked. 

215 

216 Notes 

217 ----- 

218 Default implementation of this method can work in simple cases. If 

219 the extension only supports single schema version than that version is 

220 returned. If the extension supports multiple schema versions and 

221 ``schema_version`` is not `None` then ``schema_version`` is returned. 

222 If the extension supports multiple schema versions, but 

223 ``schema_version`` is `None` it calls ``_newDefaultSchemaVersion`` 

224 method which needs to be reimplemented in a subsclass. 

225 """ 

226 my_versions = cls.currentVersions() 

227 if not my_versions: 

228 return None 

229 elif len(my_versions) == 1: 

230 return my_versions[0] 

231 else: 

232 if schema_version is not None: 

233 assert schema_version in my_versions, "Schema version must be compatible." 

234 return schema_version 

235 else: 

236 return cls._newDefaultSchemaVersion() 

237 

238 @classmethod 

239 def _newDefaultSchemaVersion(cls) -> VersionTuple: 

240 """Return default shema version for new registry for extensions that 

241 support multiple schema versions. 

242 

243 Notes 

244 ----- 

245 Default implementation simply raises an exception. Managers which 

246 support multiple schema versions must re-implement this method. 

247 """ 

248 raise NotImplementedError( 

249 f"Extension {cls.extensionName()} supports multiple schema versions, " 

250 "its newSchemaVersion() method needs to be re-implemented." 

251 ) 

252 

253 @classmethod 

254 def checkCompatibility(cls, registry_schema_version: VersionTuple, update: bool) -> None: 

255 """Check that schema version defined in registry is compatible with 

256 current implementation. 

257 

258 Parameters 

259 ---------- 

260 registry_schema_version : `VersionTuple` 

261 Schema version that exists in registry or defined in a 

262 configuration for a registry to be created. 

263 update : `bool` 

264 If True then read-write access is expected. 

265 

266 Raises 

267 ------ 

268 IncompatibleVersionError 

269 Raised if schema version is not supported by implementation. 

270 

271 Notes 

272 ----- 

273 Default implementation uses `VersionTuple.checkCompatibility` on 

274 the versions returned from `currentVersions` method. Subclasses that 

275 support different compatibility model will overwrite this method. 

276 """ 

277 # Extensions that do not define current versions are compatible with 

278 # anything. 

279 if my_versions := cls.currentVersions(): 

280 # If there are multiple version supported one of them should be 

281 # compatible to succeed. 

282 for version in my_versions: 

283 if version.checkCompatibility(registry_schema_version, update): 

284 return 

285 raise IncompatibleVersionError( 

286 f"Extension versions {my_versions} is not compatible with registry " 

287 f"schema version {registry_schema_version} for extension {cls.extensionName()}" 

288 ) 

289 

290 @classmethod 

291 def checkNewSchemaVersion(cls, schema_version: VersionTuple) -> None: 

292 """Verify that requested schema version can be created by an extension. 

293 

294 Parameters 

295 ---------- 

296 schema_version : `VersionTuple` 

297 Schema version that this extension is asked to create. 

298 

299 Notes 

300 ----- 

301 This method may be used only occasionally when a specific schema 

302 version is given in a regisitry config file. This can be used with an 

303 extension that supports multiple schem versions to make it create new 

304 schema with a non-default version number. Default implementation 

305 compares requested version with one of the version returned from 

306 `currentVersions`. 

307 """ 

308 if my_versions := cls.currentVersions(): 

309 # If there are multiple version supported one of them should be 

310 # compatible to succeed. 

311 for version in my_versions: 

312 # Need to have an exact match 

313 if version == schema_version: 

314 return 

315 raise IncompatibleVersionError( 

316 f"Extension {cls.extensionName()} cannot create schema version {schema_version}, " 

317 f"supported schema versions: {my_versions}" 

318 )