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]) -> str: 

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

146 

147 Parameters 

148 ---------- 

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

150 Set of tables comprising the schema. 

151 

152 Returns 

153 ------- 

154 digest : `str` 

155 String representation of the digest of the schema. 

156 

157 Notes 

158 ----- 

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

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

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

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

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

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

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

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

167 into total digest). 

168 """ 

169 

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

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

172 """ 

173 tableSchemaRepr = [table.name] 

174 schemaReps = [] 

175 for column in table.columns: 

176 columnRep = f"COL,{column.name},{column.type}" 

177 if column.primary_key: 

178 columnRep += ",PK" 

179 if column.nullable: 

180 columnRep += ",NULL" 

181 schemaReps += [columnRep] 

182 for fkConstr in table.foreign_key_constraints: 

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

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

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

186 fkRep = ",".join(fkReps) 

187 schemaReps += [fkRep] 

188 # sort everything to keep it stable 

189 schemaReps.sort() 

190 tableSchemaRepr += schemaReps 

191 return ";".join(tableSchemaRepr) 

192 

193 md5 = hashlib.md5() 

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

195 for tableRepr in tableSchemas: 

196 md5.update(tableRepr.encode()) 

197 digest = md5.hexdigest() 

198 return digest