| 1 | #!/usr/bin/env python |
|---|
| 2 | # -*- coding: utf-8 -*- |
|---|
| 3 | |
|---|
| 4 | import os |
|---|
| 5 | import re |
|---|
| 6 | import shutil |
|---|
| 7 | |
|---|
| 8 | from migrate.versioning import exceptions, pathed, script |
|---|
| 9 | |
|---|
| 10 | |
|---|
| 11 | class VerNum(object): |
|---|
| 12 | """A version number""" |
|---|
| 13 | |
|---|
| 14 | _instances = dict() |
|---|
| 15 | |
|---|
| 16 | def __new__(cls, value): |
|---|
| 17 | val = str(value) |
|---|
| 18 | if val not in cls._instances: |
|---|
| 19 | cls._instances[val] = super(VerNum, cls).__new__(cls) |
|---|
| 20 | ret = cls._instances[val] |
|---|
| 21 | return ret |
|---|
| 22 | |
|---|
| 23 | def __init__(self,value): |
|---|
| 24 | self.value = str(int(value)) |
|---|
| 25 | if self < 0: |
|---|
| 26 | raise ValueError("Version number cannot be negative") |
|---|
| 27 | |
|---|
| 28 | def __add__(self, value): |
|---|
| 29 | ret = int(self) + int(value) |
|---|
| 30 | return VerNum(ret) |
|---|
| 31 | |
|---|
| 32 | def __sub__(self, value): |
|---|
| 33 | return self + (int(value) * -1) |
|---|
| 34 | |
|---|
| 35 | def __cmp__(self, value): |
|---|
| 36 | return int(self) - int(value) |
|---|
| 37 | |
|---|
| 38 | def __repr__(self): |
|---|
| 39 | return "<VerNum(%s)>" % self.value |
|---|
| 40 | |
|---|
| 41 | def __str__(self): |
|---|
| 42 | return str(self.value) |
|---|
| 43 | |
|---|
| 44 | def __int__(self): |
|---|
| 45 | return int(self.value) |
|---|
| 46 | |
|---|
| 47 | |
|---|
| 48 | class Collection(pathed.Pathed): |
|---|
| 49 | """A collection of versioning scripts in a repository""" |
|---|
| 50 | |
|---|
| 51 | FILENAME_WITH_VERSION = re.compile(r'^(\d{3,}).*') |
|---|
| 52 | |
|---|
| 53 | def __init__(self, path): |
|---|
| 54 | """Collect current version scripts in repository""" |
|---|
| 55 | super(Collection, self).__init__(path) |
|---|
| 56 | |
|---|
| 57 | # Create temporary list of files, allowing skipped version numbers. |
|---|
| 58 | files = os.listdir(path) |
|---|
| 59 | if '1' in files: |
|---|
| 60 | # deprecation |
|---|
| 61 | raise Exception('It looks like you have a repository in the old ' |
|---|
| 62 | 'format (with directories for each version). ' |
|---|
| 63 | 'Please convert repository before proceeding.') |
|---|
| 64 | |
|---|
| 65 | tempVersions = dict() |
|---|
| 66 | for filename in files: |
|---|
| 67 | match = self.FILENAME_WITH_VERSION.match(filename) |
|---|
| 68 | if match: |
|---|
| 69 | num = int(match.group(1)) |
|---|
| 70 | tempVersions.setdefault(num, []).append(filename) |
|---|
| 71 | else: |
|---|
| 72 | pass # Must be a helper file or something, let's ignore it. |
|---|
| 73 | |
|---|
| 74 | # Create the versions member where the keys |
|---|
| 75 | # are VerNum's and the values are Version's. |
|---|
| 76 | self.versions = dict() |
|---|
| 77 | for num, files in tempVersions.items(): |
|---|
| 78 | self.versions[VerNum(num)] = Version(num, path, files) |
|---|
| 79 | |
|---|
| 80 | @property |
|---|
| 81 | def latest(self): |
|---|
| 82 | return max([VerNum(0)] + self.versions.keys()) |
|---|
| 83 | |
|---|
| 84 | def create_new_python_version(self, description, **k): |
|---|
| 85 | """Create Python files for new version""" |
|---|
| 86 | ver = self.latest + 1 |
|---|
| 87 | extra = str_to_filename(description) |
|---|
| 88 | |
|---|
| 89 | if extra: |
|---|
| 90 | if extra == '_': |
|---|
| 91 | extra = '' |
|---|
| 92 | elif not extra.startswith('_'): |
|---|
| 93 | extra = '_%s' % extra |
|---|
| 94 | |
|---|
| 95 | filename = '%03d%s.py' % (ver, extra) |
|---|
| 96 | filepath = self._version_path(filename) |
|---|
| 97 | |
|---|
| 98 | if os.path.exists(filepath): |
|---|
| 99 | raise Exception('Script already exists: %s' % filepath) |
|---|
| 100 | else: |
|---|
| 101 | script.PythonScript.create(filepath) |
|---|
| 102 | |
|---|
| 103 | self.versions[ver] = Version(ver, self.path, [filename]) |
|---|
| 104 | |
|---|
| 105 | def create_new_sql_version(self, database, **k): |
|---|
| 106 | """Create SQL files for new version""" |
|---|
| 107 | ver = self.latest + 1 |
|---|
| 108 | self.versions[ver] = Version(ver, self.path, []) |
|---|
| 109 | |
|---|
| 110 | # Create new files. |
|---|
| 111 | for op in ('upgrade', 'downgrade'): |
|---|
| 112 | filename = '%03d_%s_%s.sql' % (ver, database, op) |
|---|
| 113 | filepath = self._version_path(filename) |
|---|
| 114 | if os.path.exists(filepath): |
|---|
| 115 | raise Exception('Script already exists: %s' % filepath) |
|---|
| 116 | else: |
|---|
| 117 | open(filepath, "w").close() |
|---|
| 118 | self.versions[ver].add_script(filepath) |
|---|
| 119 | |
|---|
| 120 | def version(self, vernum=None): |
|---|
| 121 | """Returns latest Version if vernum is not given. \ |
|---|
| 122 | Otherwise, returns wanted version""" |
|---|
| 123 | if vernum is None: |
|---|
| 124 | vernum = self.latest |
|---|
| 125 | return self.versions[VerNum(vernum)] |
|---|
| 126 | |
|---|
| 127 | @classmethod |
|---|
| 128 | def clear(cls): |
|---|
| 129 | super(Collection, cls).clear() |
|---|
| 130 | |
|---|
| 131 | def _version_path(self, ver): |
|---|
| 132 | """Returns path of file in versions repository""" |
|---|
| 133 | return os.path.join(self.path, str(ver)) |
|---|
| 134 | |
|---|
| 135 | |
|---|
| 136 | class Version(object): |
|---|
| 137 | """A single version in a collection """ |
|---|
| 138 | |
|---|
| 139 | def __init__(self, vernum, path, filelist): |
|---|
| 140 | self.version = VerNum(vernum) |
|---|
| 141 | |
|---|
| 142 | # Collect scripts in this folder |
|---|
| 143 | self.sql = dict() |
|---|
| 144 | self.python = None |
|---|
| 145 | |
|---|
| 146 | for script in filelist: |
|---|
| 147 | self.add_script(os.path.join(path, script)) |
|---|
| 148 | |
|---|
| 149 | def script(self, database=None, operation=None): |
|---|
| 150 | """Returns SQL or Python Script""" |
|---|
| 151 | for db in (database, 'default'): |
|---|
| 152 | # Try to return a .sql script first |
|---|
| 153 | try: |
|---|
| 154 | return self.sql[db][operation] |
|---|
| 155 | except KeyError: |
|---|
| 156 | continue # No .sql script exists |
|---|
| 157 | |
|---|
| 158 | # TODO: maybe add force Python parameter? |
|---|
| 159 | ret = self.python |
|---|
| 160 | |
|---|
| 161 | assert ret is not None |
|---|
| 162 | return ret |
|---|
| 163 | |
|---|
| 164 | # deprecated? |
|---|
| 165 | @classmethod |
|---|
| 166 | def create(cls, path): |
|---|
| 167 | os.mkdir(path) |
|---|
| 168 | # create the version as a proper Python package |
|---|
| 169 | initfile = os.path.join(path, "__init__.py") |
|---|
| 170 | if not os.path.exists(initfile): |
|---|
| 171 | # just touch the file |
|---|
| 172 | open(initfile, "w").close() |
|---|
| 173 | try: |
|---|
| 174 | ret = cls(path) |
|---|
| 175 | except: |
|---|
| 176 | os.rmdir(path) |
|---|
| 177 | raise |
|---|
| 178 | return ret |
|---|
| 179 | |
|---|
| 180 | def add_script(self, path): |
|---|
| 181 | """Add script to Collection/Version""" |
|---|
| 182 | if path.endswith(Extensions.py): |
|---|
| 183 | self._add_script_py(path) |
|---|
| 184 | elif path.endswith(Extensions.sql): |
|---|
| 185 | self._add_script_sql(path) |
|---|
| 186 | |
|---|
| 187 | SQL_FILENAME = re.compile(r'^(\d+)_([^_]+)_([^_]+).sql') |
|---|
| 188 | |
|---|
| 189 | def _add_script_sql(self, path): |
|---|
| 190 | match = self.SQL_FILENAME.match(os.path.basename(path)) |
|---|
| 191 | |
|---|
| 192 | if match: |
|---|
| 193 | version, dbms, op = match.group(1), match.group(2), match.group(3) |
|---|
| 194 | else: |
|---|
| 195 | raise exceptions.ScriptError("Invalid SQL script name %s" % path) |
|---|
| 196 | |
|---|
| 197 | # File the script into a dictionary |
|---|
| 198 | self.sql.setdefault(dbms, {})[op] = script.SqlScript(path) |
|---|
| 199 | |
|---|
| 200 | def _add_script_py(self, path): |
|---|
| 201 | if self.python is not None: |
|---|
| 202 | raise Exception('You can only have one Python script per version,' |
|---|
| 203 | ' but you have: %s and %s' % (self.python, path)) |
|---|
| 204 | self.python = script.PythonScript(path) |
|---|
| 205 | |
|---|
| 206 | class Extensions: |
|---|
| 207 | """A namespace for file extensions""" |
|---|
| 208 | py = 'py' |
|---|
| 209 | sql = 'sql' |
|---|
| 210 | |
|---|
| 211 | def str_to_filename(s): |
|---|
| 212 | """Replaces spaces, (double and single) quotes |
|---|
| 213 | and double underscores to underscores |
|---|
| 214 | """ |
|---|
| 215 | |
|---|
| 216 | s = s.replace(' ', '_').replace('"', '_').replace("'", '_') |
|---|
| 217 | while '__' in s: |
|---|
| 218 | s = s.replace('__', '_') |
|---|
| 219 | return s |
|---|