Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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 program is free software: you can redistribute it and/or modify 

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

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

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

21 

22from __future__ import annotations 

23 

24__all__ = [ 

25 "VersionTuple", 

26 "VersionedExtension", 

27] 

28 

29from abc import ABC, abstractmethod 

30import hashlib 

31from typing import ( 

32 Iterable, 

33 NamedTuple, 

34 Optional, 

35) 

36 

37import sqlalchemy 

38 

39 

40class VersionTuple(NamedTuple): 

41 """Class representing a version number. 

42 

43 Parameters 

44 ---------- 

45 major, minor, patch : `int` 

46 Version number components 

47 """ 

48 major: int 

49 minor: int 

50 patch: int 

51 

52 @classmethod 

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

54 """Extract version number from a string. 

55 

56 Parameters 

57 ---------- 

58 versionStr : `str` 

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

60 present. 

61 

62 Returns 

63 ------- 

64 version : `VersionTuple` 

65 Parsed version tuple. 

66 

67 Raises 

68 ------ 

69 ValueError 

70 Raised if string has an invalid format. 

71 """ 

72 try: 

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

74 except ValueError as exc: 

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

76 if len(version) != 3: 

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

78 return cls(*version) 

79 

80 def __str__(self) -> str: 

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

82 """ 

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

84 

85 

86class VersionedExtension(ABC): 

87 """Interface for extension classes with versions. 

88 """ 

89 

90 @classmethod 

91 @abstractmethod 

92 def currentVersion(cls) -> Optional[VersionTuple]: 

93 """Return extension version as defined by current implementation. 

94 

95 This method can return ``None`` if an extension does not require 

96 its version to be saved or checked. 

97 

98 Returns 

99 ------- 

100 version : `VersionTuple` 

101 Current extension version or ``None``. 

102 """ 

103 raise NotImplementedError() 

104 

105 @classmethod 

106 def extensionName(cls) -> str: 

107 """Return full name of the extension. 

108 

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

110 is also stored in registry attributes. Default implementation returns 

111 full class name. 

112 

113 Returns 

114 ------- 

115 name : `str` 

116 Full extension name. 

117 """ 

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

119 

120 @abstractmethod 

121 def schemaDigest(self) -> Optional[str]: 

122 """Return digest for schema piece managed by this extension. 

123 

124 Returns 

125 ------- 

126 digest : `str` or `None` 

127 String representation of the digest of the schema, ``None`` should 

128 be returned if schema digest is not to be saved or checked. The 

129 length of the returned string cannot exceed the length of the 

130 "value" column of butler attributes table, currently 65535 

131 characters. 

132 

133 Notes 

134 ----- 

135 There is no exact definition of digest format, any string should work. 

136 The only requirement for string contents is that it has to remain 

137 stable over time if schema does not change but it should produce 

138 different string for any change in the schema. In many cases default 

139 implementation in `_defaultSchemaDigest` can be used as a reasonable 

140 choice. 

141 """ 

142 raise NotImplementedError() 

143 

144 def _defaultSchemaDigest(self, tables: Iterable[sqlalchemy.schema.Table], 

145 dialect: sqlalchemy.engine.Dialect) -> str: 

146 """Calculate digest for a schema based on list of tables schemas. 

147 

148 Parameters 

149 ---------- 

150 tables : iterable [`sqlalchemy.schema.Table`] 

151 Set of tables comprising the schema. 

152 dialect : `sqlalchemy.engine.Dialect`, optional 

153 Dialect used to stringify types; needed to support dialect-specific 

154 types. 

155 

156 Returns 

157 ------- 

158 digest : `str` 

159 String representation of the digest of the schema. 

160 

161 Notes 

162 ----- 

163 It is not specified what kind of implementation is used to calculate 

164 digest string. The only requirement for that is that result should be 

165 stable over time as this digest string will be stored in the database. 

166 It should detect (by producing different digests) sensible changes to 

167 the schema, but it also should be stable w.r.t. changes that do 

168 not actually change the schema (e.g. change in the order of columns or 

169 keys.) Current implementation is likely incomplete in that it does not 

170 detect all possible changes (e.g. some constraints may not be included 

171 into total digest). 

172 """ 

173 

174 def tableSchemaRepr(table: sqlalchemy.schema.Table) -> str: 

175 """Make string representation of a single table schema. 

176 """ 

177 tableSchemaRepr = [table.name] 

178 schemaReps = [] 

179 for column in table.columns: 

180 columnRep = f"COL,{column.name},{column.type.compile(dialect=dialect)}" 

181 if column.primary_key: 

182 columnRep += ",PK" 

183 if column.nullable: 

184 columnRep += ",NULL" 

185 schemaReps += [columnRep] 

186 for fkConstr in table.foreign_key_constraints: 

187 # for foreign key we include only one side of relations into 

188 # digest, other side could be managed by different extension 

189 fkReps = ["FK", fkConstr.name] + [fk.column.name for fk in fkConstr.elements] 

190 fkRep = ",".join(fkReps) 

191 schemaReps += [fkRep] 

192 # sort everything to keep it stable 

193 schemaReps.sort() 

194 tableSchemaRepr += schemaReps 

195 return ";".join(tableSchemaRepr) 

196 

197 md5 = hashlib.md5() 

198 tableSchemas = sorted(tableSchemaRepr(table) for table in tables) 

199 for tableRepr in tableSchemas: 

200 md5.update(tableRepr.encode()) 

201 digest = md5.hexdigest() 

202 return digest