Coverage for python/lsst/daf/butler/registry/interfaces/_versioning.py: 30%
Shortcuts 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
Shortcuts 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/>.
22from __future__ import annotations
24__all__ = [
25 "VersionTuple",
26 "VersionedExtension",
27]
29from abc import ABC, abstractmethod
30import hashlib
31from typing import (
32 Iterable,
33 NamedTuple,
34 Optional,
35)
37import sqlalchemy
40class VersionTuple(NamedTuple):
41 """Class representing a version number.
43 Parameters
44 ----------
45 major, minor, patch : `int`
46 Version number components
47 """
48 major: int
49 minor: int
50 patch: int
52 @classmethod
53 def fromString(cls, versionStr: str) -> VersionTuple:
54 """Extract version number from a string.
56 Parameters
57 ----------
58 versionStr : `str`
59 Version number in string form "X.Y.Z", all components must be
60 present.
62 Returns
63 -------
64 version : `VersionTuple`
65 Parsed version tuple.
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)
80 def __str__(self) -> str:
81 """Transform version tuple into a canonical string form.
82 """
83 return f"{self.major}.{self.minor}.{self.patch}"
86class VersionedExtension(ABC):
87 """Interface for extension classes with versions.
88 """
90 @classmethod
91 @abstractmethod
92 def currentVersion(cls) -> Optional[VersionTuple]:
93 """Return extension version as defined by current implementation.
95 This method can return ``None`` if an extension does not require
96 its version to be saved or checked.
98 Returns
99 -------
100 version : `VersionTuple`
101 Current extension version or ``None``.
102 """
103 raise NotImplementedError()
105 @classmethod
106 def extensionName(cls) -> str:
107 """Return full name of the extension.
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.
113 Returns
114 -------
115 name : `str`
116 Full extension name.
117 """
118 return f"{cls.__module__}.{cls.__name__}"
120 @abstractmethod
121 def schemaDigest(self) -> Optional[str]:
122 """Return digest for schema piece managed by this extension.
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.
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()
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.
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.
156 Returns
157 -------
158 digest : `str`
159 String representation of the digest of the schema.
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 """
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)
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