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

65 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-06 10:53 +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 Parameters 

52 ---------- 

53 major, minor, patch : `int` 

54 Version number components 

55 """ 

56 

57 major: int 

58 minor: int 

59 patch: int 

60 

61 @classmethod 

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

63 """Extract version number from a string. 

64 

65 Parameters 

66 ---------- 

67 versionStr : `str` 

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

69 present. 

70 

71 Returns 

72 ------- 

73 version : `VersionTuple` 

74 Parsed version tuple. 

75 

76 Raises 

77 ------ 

78 ValueError 

79 Raised if string has an invalid format. 

80 """ 

81 try: 

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

83 except ValueError as exc: 

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

85 if len(version) != 3: 

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

87 return cls(*version) 

88 

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

90 """Compare implementation schema version with schema version in 

91 registry. 

92 

93 Parameters 

94 ---------- 

95 registry_schema_version : `VersionTuple` 

96 Schema version that exists in registry or defined in a 

97 configuration for a registry to be created. 

98 update : `bool` 

99 If True then read-write access is expected. 

100 

101 Returns 

102 ------- 

103 compatible : `bool` 

104 True if schema versions are compatible. 

105 

106 Notes 

107 ----- 

108 This method implements default rules for checking schema compatibility: 

109 

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

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

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

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

114 - otherwise, different patch versions are totally compatible. 

115 

116 Extensions that implement different versioning model will need to 

117 override their `VersionedExtension.checkCompatibility` method. 

118 """ 

119 if self.major != registry_schema_version.major: 

120 # different major versions are not compatible at all 

121 return False 

122 if self.minor != registry_schema_version.minor: 

123 # different minor versions are backward compatible for read 

124 # access only 

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

126 # patch difference does not matter 

127 return True 

128 

129 def __str__(self) -> str: 

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

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

132 

133 

134class VersionedExtension(ABC): 

135 """Interface for extension classes with versions. 

136 

137 Parameters 

138 ---------- 

139 registry_schema_version : `VersionTuple` or `None` 

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

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

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

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

144 is guaranteed that this version has passed compatibility check. 

145 """ 

146 

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

148 self._registry_schema_version = registry_schema_version 

149 

150 @classmethod 

151 def extensionName(cls) -> str: 

152 """Return full name of the extension. 

153 

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

155 is also stored in registry attributes. Default implementation returns 

156 full class name. 

157 

158 Returns 

159 ------- 

160 name : `str` 

161 Full extension name. 

162 """ 

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

164 

165 @classmethod 

166 @abstractmethod 

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

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

169 

170 Returns 

171 ------- 

172 version : `list` [`VersionTuple`] 

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

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

175 """ 

176 raise NotImplementedError() 

177 

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

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

180 

181 Returns 

182 ------- 

183 version : `VersionTuple` or `None` 

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

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

186 

187 Notes 

188 ----- 

189 Extension classes that support multiple schema versions need to 

190 override `_newDefaultSchemaVersion` method. 

191 """ 

192 return self.clsNewSchemaVersion(self._registry_schema_version) 

193 

194 @classmethod 

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

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

197 registry database. 

198 

199 Parameters 

200 ---------- 

201 schema_version : `VersionTuple` or `None` 

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

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

204 compatible with `currentVersions`. 

205 

206 Returns 

207 ------- 

208 version : `VersionTuple` or `None` 

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

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

211 

212 Notes 

213 ----- 

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

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

216 returned. If the extension supports multiple schema versions and 

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

218 If the extension supports multiple schema versions, but 

219 ``schema_version`` is `None` it calls ``_newDefaultSchemaVersion`` 

220 method which needs to be reimplemented in a subsclass. 

221 """ 

222 my_versions = cls.currentVersions() 

223 if not my_versions: 

224 return None 

225 elif len(my_versions) == 1: 

226 return my_versions[0] 

227 else: 

228 if schema_version is not None: 

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

230 return schema_version 

231 else: 

232 return cls._newDefaultSchemaVersion() 

233 

234 @classmethod 

235 def _newDefaultSchemaVersion(cls) -> VersionTuple: 

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

237 support multiple schema versions. 

238 

239 Notes 

240 ----- 

241 Default implementation simply raises an exception. Managers which 

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

243 """ 

244 raise NotImplementedError( 

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

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

247 ) 

248 

249 @classmethod 

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

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

252 current implementation. 

253 

254 Parameters 

255 ---------- 

256 registry_schema_version : `VersionTuple` 

257 Schema version that exists in registry or defined in a 

258 configuration for a registry to be created. 

259 update : `bool` 

260 If True then read-write access is expected. 

261 

262 Raises 

263 ------ 

264 IncompatibleVersionError 

265 Raised if schema version is not supported by implementation. 

266 

267 Notes 

268 ----- 

269 Default implementation uses `VersionTuple.checkCompatibility` on 

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

271 support different compatibility model will overwrite this method. 

272 """ 

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

274 # anything. 

275 if my_versions := cls.currentVersions(): 

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

277 # compatible to succeed. 

278 for version in my_versions: 

279 if version.checkCompatibility(registry_schema_version, update): 

280 return 

281 raise IncompatibleVersionError( 

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

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

284 ) 

285 

286 @classmethod 

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

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

289 

290 Parameters 

291 ---------- 

292 schema_version : `VersionTuple` 

293 Schema version that this extension is asked to create. 

294 

295 Notes 

296 ----- 

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

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

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

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

301 compares requested version with one of the version returned from 

302 `currentVersions`. 

303 """ 

304 if my_versions := cls.currentVersions(): 

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

306 # compatible to succeed. 

307 for version in my_versions: 

308 # Need to have an exact match 

309 if version == schema_version: 

310 return 

311 raise IncompatibleVersionError( 

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

313 f"supported schema versions: {my_versions}" 

314 )