debian-south/south/creator/changes.py

507 lines
24 KiB
Python

"""
Contains things to detect changes - either using options passed in on the
commandline, or by using autodetection, etc.
"""
from __future__ import print_function
from django.db import models
from django.contrib.contenttypes.generic import GenericRelation
from django.utils.datastructures import SortedDict
from south.creator.freezer import remove_useless_attributes, freeze_apps, model_key
from south.utils import auto_through
from south.utils.py3 import string_types
class BaseChanges(object):
"""
Base changes class.
"""
def suggest_name(self):
return ''
def split_model_def(self, model, model_def):
"""
Given a model and its model def (a dict of field: triple), returns three
items: the real fields dict, the Meta dict, and the M2M fields dict.
"""
real_fields = SortedDict()
meta = SortedDict()
m2m_fields = SortedDict()
for name, triple in model_def.items():
if name == "Meta":
meta = triple
elif isinstance(model._meta.get_field_by_name(name)[0], models.ManyToManyField):
m2m_fields[name] = triple
else:
real_fields[name] = triple
return real_fields, meta, m2m_fields
def current_model_from_key(self, key):
app_label, model_name = key.split(".")
return models.get_model(app_label, model_name)
def current_field_from_key(self, key, fieldname):
app_label, model_name = key.split(".")
# Special, for the magical field from order_with_respect_to
if fieldname == "_order":
field = models.IntegerField()
field.name = "_order"
field.attname = "_order"
field.column = "_order"
field.default = 0
return field
# Otherwise, normal.
return models.get_model(app_label, model_name)._meta.get_field_by_name(fieldname)[0]
class AutoChanges(BaseChanges):
"""
Detects changes by 'diffing' two sets of frozen model definitions.
"""
# Field types we don't generate add/remove field changes for.
IGNORED_FIELD_TYPES = [
GenericRelation,
]
def __init__(self, migrations, old_defs, old_orm, new_defs):
self.migrations = migrations
self.old_defs = old_defs
self.old_orm = old_orm
self.new_defs = new_defs
def suggest_name(self):
parts = ["auto"]
for change_name, params in self.get_changes():
if change_name == "AddModel":
parts.append("add_%s" % params['model']._meta.object_name.lower())
elif change_name == "DeleteModel":
parts.append("del_%s" % params['model']._meta.object_name.lower())
elif change_name == "AddField":
parts.append("add_field_%s_%s" % (
params['model']._meta.object_name.lower(),
params['field'].name,
))
elif change_name == "DeleteField":
parts.append("del_field_%s_%s" % (
params['model']._meta.object_name.lower(),
params['field'].name,
))
elif change_name == "ChangeField":
parts.append("chg_field_%s_%s" % (
params['model']._meta.object_name.lower(),
params['new_field'].name,
))
elif change_name == "AddUnique":
parts.append("add_unique_%s_%s" % (
params['model']._meta.object_name.lower(),
"_".join([x.name for x in params['fields']]),
))
elif change_name == "DeleteUnique":
parts.append("del_unique_%s_%s" % (
params['model']._meta.object_name.lower(),
"_".join([x.name for x in params['fields']]),
))
elif change_name == "AddIndex":
parts.append("add_index_%s_%s" % (
params['model']._meta.object_name.lower(),
"_".join([x.name for x in params['fields']]),
))
elif change_name == "DeleteIndex":
parts.append("del_index_%s_%s" % (
params['model']._meta.object_name.lower(),
"_".join([x.name for x in params['fields']]),
))
return ("__".join(parts))[:70]
def get_changes(self):
"""
Returns the difference between the old and new sets of models as a 5-tuple:
added_models, deleted_models, added_fields, deleted_fields, changed_fields
"""
deleted_models = set()
# See if anything's vanished
for key in self.old_defs:
if key not in self.new_defs:
# We shouldn't delete it if it was managed=False
old_fields, old_meta, old_m2ms = self.split_model_def(self.old_orm[key], self.old_defs[key])
if old_meta.get("managed", "True") != "False":
# Alright, delete it.
yield ("DeleteModel", {
"model": self.old_orm[key],
"model_def": old_fields,
})
# Also make sure we delete any M2Ms it had.
for fieldname in old_m2ms:
# Only delete its stuff if it wasn't a through=.
field = self.old_orm[key + ":" + fieldname]
if auto_through(field):
yield ("DeleteM2M", {"model": self.old_orm[key], "field": field})
# And any index/uniqueness constraints it had
for attr, operation in (("unique_together", "DeleteUnique"), ("index_together", "DeleteIndex")):
together = eval(old_meta.get(attr, "[]"))
if together:
# If it's only a single tuple, make it into the longer one
if isinstance(together[0], string_types):
together = [together]
# For each combination, make an action for it
for fields in together:
yield (operation, {
"model": self.old_orm[key],
"fields": [self.old_orm[key]._meta.get_field_by_name(x)[0] for x in fields],
})
# We always add it in here so we ignore it later
deleted_models.add(key)
# Or appeared
for key in self.new_defs:
if key not in self.old_defs:
# We shouldn't add it if it's managed=False
new_fields, new_meta, new_m2ms = self.split_model_def(self.current_model_from_key(key), self.new_defs[key])
if new_meta.get("managed", "True") != "False":
yield ("AddModel", {
"model": self.current_model_from_key(key),
"model_def": new_fields,
})
# Also make sure we add any M2Ms it has.
for fieldname in new_m2ms:
# Only create its stuff if it wasn't a through=.
field = self.current_field_from_key(key, fieldname)
if auto_through(field):
yield ("AddM2M", {"model": self.current_model_from_key(key), "field": field})
# And any index/uniqueness constraints it has
for attr, operation in (("unique_together", "AddUnique"), ("index_together", "AddIndex")):
together = eval(new_meta.get(attr, "[]"))
if together:
# If it's only a single tuple, make it into the longer one
if isinstance(together[0], string_types):
together = [together]
# For each combination, make an action for it
for fields in together:
yield (operation, {
"model": self.current_model_from_key(key),
"fields": [self.current_model_from_key(key)._meta.get_field_by_name(x)[0] for x in fields],
})
# Now, for every model that's stayed the same, check its fields.
for key in self.old_defs:
if key not in deleted_models:
old_fields, old_meta, old_m2ms = self.split_model_def(self.old_orm[key], self.old_defs[key])
new_fields, new_meta, new_m2ms = self.split_model_def(self.current_model_from_key(key), self.new_defs[key])
# Do nothing for models which are now not managed.
if new_meta.get("managed", "True") == "False":
continue
# Find fields that have vanished.
for fieldname in old_fields:
if fieldname not in new_fields:
# Don't do it for any fields we're ignoring
field = self.old_orm[key + ":" + fieldname]
field_allowed = True
for field_type in self.IGNORED_FIELD_TYPES:
if isinstance(field, field_type):
field_allowed = False
if field_allowed:
# Looks alright.
yield ("DeleteField", {
"model": self.old_orm[key],
"field": field,
"field_def": old_fields[fieldname],
})
# And ones that have appeared
for fieldname in new_fields:
if fieldname not in old_fields:
# Don't do it for any fields we're ignoring
field = self.current_field_from_key(key, fieldname)
field_allowed = True
for field_type in self.IGNORED_FIELD_TYPES:
if isinstance(field, field_type):
field_allowed = False
if field_allowed:
# Looks alright.
yield ("AddField", {
"model": self.current_model_from_key(key),
"field": field,
"field_def": new_fields[fieldname],
})
# Find M2Ms that have vanished
for fieldname in old_m2ms:
if fieldname not in new_m2ms:
# Only delete its stuff if it wasn't a through=.
field = self.old_orm[key + ":" + fieldname]
if auto_through(field):
yield ("DeleteM2M", {"model": self.old_orm[key], "field": field})
# Find M2Ms that have appeared
for fieldname in new_m2ms:
if fieldname not in old_m2ms:
# Only create its stuff if it wasn't a through=.
field = self.current_field_from_key(key, fieldname)
if auto_through(field):
yield ("AddM2M", {"model": self.current_model_from_key(key), "field": field})
# For the ones that exist in both models, see if they were changed
for fieldname in set(old_fields).intersection(set(new_fields)):
# Non-index changes
if self.different_attributes(
remove_useless_attributes(old_fields[fieldname], True, True),
remove_useless_attributes(new_fields[fieldname], True, True)):
yield ("ChangeField", {
"model": self.current_model_from_key(key),
"old_field": self.old_orm[key + ":" + fieldname],
"new_field": self.current_field_from_key(key, fieldname),
"old_def": old_fields[fieldname],
"new_def": new_fields[fieldname],
})
# Index changes
old_field = self.old_orm[key + ":" + fieldname]
new_field = self.current_field_from_key(key, fieldname)
if not old_field.db_index and new_field.db_index:
# They've added an index.
yield ("AddIndex", {
"model": self.current_model_from_key(key),
"fields": [new_field],
})
if old_field.db_index and not new_field.db_index:
# They've removed an index.
yield ("DeleteIndex", {
"model": self.old_orm[key],
"fields": [old_field],
})
# See if their uniques have changed
if old_field.unique != new_field.unique:
# Make sure we look at the one explicitly given to see what happened
if new_field.unique:
yield ("AddUnique", {
"model": self.current_model_from_key(key),
"fields": [new_field],
})
else:
yield ("DeleteUnique", {
"model": self.old_orm[key],
"fields": [old_field],
})
# See if there's any M2Ms that have changed.
for fieldname in set(old_m2ms).intersection(set(new_m2ms)):
old_field = self.old_orm[key + ":" + fieldname]
new_field = self.current_field_from_key(key, fieldname)
# Have they _added_ a through= ?
if auto_through(old_field) and not auto_through(new_field):
yield ("DeleteM2M", {"model": self.old_orm[key], "field": old_field})
# Have they _removed_ a through= ?
if not auto_through(old_field) and auto_through(new_field):
yield ("AddM2M", {"model": self.current_model_from_key(key), "field": new_field})
## See if the {index,unique}_togethers have changed
for attr, add_operation, del_operation in (("unique_together", "AddUnique", "DeleteUnique"), ("index_together", "AddIndex", "DeleteIndex")):
# First, normalise them into lists of sets.
old_together = eval(old_meta.get(attr, "[]"))
new_together = eval(new_meta.get(attr, "[]"))
if old_together and isinstance(old_together[0], string_types):
old_together = [old_together]
if new_together and isinstance(new_together[0], string_types):
new_together = [new_together]
old_together = frozenset(tuple(o) for o in old_together)
new_together = frozenset(tuple(n) for n in new_together)
# See if any appeared or disappeared
disappeared = old_together.difference(new_together)
appeared = new_together.difference(old_together)
for item in disappeared:
yield (del_operation, {
"model": self.old_orm[key],
"fields": [self.old_orm[key + ":" + x] for x in item],
})
for item in appeared:
yield (add_operation, {
"model": self.current_model_from_key(key),
"fields": [self.current_field_from_key(key, x) for x in item],
})
@classmethod
def is_triple(cls, triple):
"Returns whether the argument is a triple."
return isinstance(triple, (list, tuple)) and len(triple) == 3 and \
isinstance(triple[0], string_types) and \
isinstance(triple[1], (list, tuple)) and \
isinstance(triple[2], dict)
@classmethod
def different_attributes(cls, old, new):
"""
Backwards-compat comparison that ignores orm. on the RHS and not the left
and which knows django.db.models.fields.CharField = models.CharField.
Has a whole load of tests in tests/autodetection.py.
"""
# If they're not triples, just do normal comparison
if not cls.is_triple(old) or not cls.is_triple(new):
return old != new
# Expand them out into parts
old_field, old_pos, old_kwd = old
new_field, new_pos, new_kwd = new
# Copy the positional and keyword arguments so we can compare them and pop off things
old_pos, new_pos = old_pos[:], new_pos[:]
old_kwd = dict(old_kwd.items())
new_kwd = dict(new_kwd.items())
# Remove comparison of the existence of 'unique', that's done elsewhere.
# TODO: Make this work for custom fields where unique= means something else?
if "unique" in old_kwd:
del old_kwd['unique']
if "unique" in new_kwd:
del new_kwd['unique']
# If the first bit is different, check it's not by dj.db.models...
if old_field != new_field:
if old_field.startswith("models.") and (new_field.startswith("django.db.models") \
or new_field.startswith("django.contrib.gis")):
if old_field.split(".")[-1] != new_field.split(".")[-1]:
return True
else:
# Remove those fields from the final comparison
old_field = new_field = ""
# If there's a positional argument in the first, and a 'to' in the second,
# see if they're actually comparable.
if (old_pos and "to" in new_kwd) and ("orm" in new_kwd['to'] and "orm" not in old_pos[0]):
# Do special comparison to fix #153
try:
if old_pos[0] != new_kwd['to'].split("'")[1].split(".")[1]:
return True
except IndexError:
pass # Fall back to next comparison
# Remove those attrs from the final comparison
old_pos = old_pos[1:]
del new_kwd['to']
return old_field != new_field or old_pos != new_pos or old_kwd != new_kwd
class ManualChanges(BaseChanges):
"""
Detects changes by reading the command line.
"""
def __init__(self, migrations, added_models, added_fields, added_indexes):
self.migrations = migrations
self.added_models = added_models
self.added_fields = added_fields
self.added_indexes = added_indexes
def suggest_name(self):
bits = []
for model_name in self.added_models:
bits.append('add_model_%s' % model_name)
for field_name in self.added_fields:
bits.append('add_field_%s' % field_name)
for index_name in self.added_indexes:
bits.append('add_index_%s' % index_name)
return '_'.join(bits).replace('.', '_')
def get_changes(self):
# Get the model defs so we can use them for the yield later
model_defs = freeze_apps([self.migrations.app_label()])
# Make the model changes
for model_name in self.added_models:
model = models.get_model(self.migrations.app_label(), model_name)
real_fields, meta, m2m_fields = self.split_model_def(model, model_defs[model_key(model)])
yield ("AddModel", {
"model": model,
"model_def": real_fields,
})
# And the field changes
for field_desc in self.added_fields:
try:
model_name, field_name = field_desc.split(".")
except (TypeError, ValueError):
raise ValueError("%r is not a valid field description." % field_desc)
model = models.get_model(self.migrations.app_label(), model_name)
real_fields, meta, m2m_fields = self.split_model_def(model, model_defs[model_key(model)])
yield ("AddField", {
"model": model,
"field": model._meta.get_field_by_name(field_name)[0],
"field_def": real_fields[field_name],
})
# And the indexes
for field_desc in self.added_indexes:
try:
model_name, field_name = field_desc.split(".")
except (TypeError, ValueError):
print("%r is not a valid field description." % field_desc)
model = models.get_model(self.migrations.app_label(), model_name)
yield ("AddIndex", {
"model": model,
"fields": [model._meta.get_field_by_name(field_name)[0]],
})
class InitialChanges(BaseChanges):
"""
Creates all models; handles --initial.
"""
def suggest_name(self):
return 'initial'
def __init__(self, migrations):
self.migrations = migrations
def get_changes(self):
# Get the frozen models for this app
model_defs = freeze_apps([self.migrations.app_label()])
for model in models.get_models(models.get_app(self.migrations.app_label())):
# Don't do anything for unmanaged, abstract or proxy models
if model._meta.abstract or getattr(model._meta, "proxy", False) or not getattr(model._meta, "managed", True):
continue
real_fields, meta, m2m_fields = self.split_model_def(model, model_defs[model_key(model)])
# Firstly, add the main table and fields
yield ("AddModel", {
"model": model,
"model_def": real_fields,
})
# Then, add any indexing/uniqueness that's around
if meta:
for attr, operation in (("unique_together", "AddUnique"), ("index_together", "AddIndex")):
together = eval(meta.get(attr, "[]"))
if together:
# If it's only a single tuple, make it into the longer one
if isinstance(together[0], string_types):
together = [together]
# For each combination, make an action for it
for fields in together:
yield (operation, {
"model": model,
"fields": [model._meta.get_field_by_name(x)[0] for x in fields],
})
# Finally, see if there's some M2M action
for name, triple in m2m_fields.items():
field = model._meta.get_field_by_name(name)[0]
# But only if it's not through=foo (#120)
if field.rel.through:
try:
# Django 1.1 and below
through_model = field.rel.through_model
except AttributeError:
# Django 1.2
through_model = field.rel.through
if (not field.rel.through) or getattr(through_model._meta, "auto_created", False):
yield ("AddM2M", {
"model": model,
"field": field,
})