django-dbdiff 0.9.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. dbdiff/__init__.py +3 -0
  2. dbdiff/apps.py +44 -0
  3. dbdiff/exceptions.py +72 -0
  4. dbdiff/fixture.py +172 -0
  5. dbdiff/plugin.py +35 -0
  6. dbdiff/sequence.py +57 -0
  7. dbdiff/serializers/__init__.py +1 -0
  8. dbdiff/serializers/base.py +80 -0
  9. dbdiff/serializers/json.py +15 -0
  10. dbdiff/test.py +58 -0
  11. dbdiff/tests/__init__.py +1 -0
  12. dbdiff/tests/decimal_test/__init__.py +1 -0
  13. dbdiff/tests/decimal_test/migrations/0001_initial.py +17 -0
  14. dbdiff/tests/decimal_test/migrations/0002_auto_20160102_0914.py +16 -0
  15. dbdiff/tests/decimal_test/migrations/__init__.py +0 -0
  16. dbdiff/tests/decimal_test/models.py +5 -0
  17. dbdiff/tests/inheritance/__init__.py +1 -0
  18. dbdiff/tests/inheritance/models.py +9 -0
  19. dbdiff/tests/nonintpk/__init__.py +1 -0
  20. dbdiff/tests/nonintpk/models.py +8 -0
  21. dbdiff/tests/project/__init__.py +1 -0
  22. dbdiff/tests/project/settings.py +107 -0
  23. dbdiff/tests/project/settings_mysql.py +21 -0
  24. dbdiff/tests/project/settings_postgresql.py +16 -0
  25. dbdiff/tests/project/settings_sqlite.py +8 -0
  26. dbdiff/tests/project/urls.py +1 -0
  27. dbdiff/tests/test_compare.py +102 -0
  28. dbdiff/tests/test_decimal.py +54 -0
  29. dbdiff/tests/test_fixture.py +25 -0
  30. dbdiff/tests/test_mixin.py +22 -0
  31. dbdiff/tests/test_plugin.py +34 -0
  32. dbdiff/tests/test_utils.py +49 -0
  33. dbdiff/utils.py +179 -0
  34. django_dbdiff-0.9.6.dist-info/METADATA +151 -0
  35. django_dbdiff-0.9.6.dist-info/RECORD +38 -0
  36. django_dbdiff-0.9.6.dist-info/WHEEL +5 -0
  37. django_dbdiff-0.9.6.dist-info/entry_points.txt +2 -0
  38. django_dbdiff-0.9.6.dist-info/top_level.txt +1 -0
dbdiff/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """dbdiff module enable diffing a fixture against the database."""
2
+
3
+ default_app_config = 'dbdiff.apps.DefaultConfig'
dbdiff/apps.py ADDED
@@ -0,0 +1,44 @@
1
+ """AppConfig for dbdiff."""
2
+
3
+ import os
4
+
5
+ from django.apps import AppConfig
6
+ from django.core.serializers import register_serializer
7
+
8
+ from .utils import patch_transaction_test_case
9
+
10
+
11
+ class DefaultConfig(AppConfig):
12
+ """
13
+ Register patched serializers and patch TransactionTestCase for sqlite.
14
+
15
+ .. py:attribute:: debug
16
+
17
+ If True, then diff commands will be printed to stdout and temporary
18
+ files will not be deleted.
19
+ """
20
+
21
+ name = 'dbdiff'
22
+ debug = False
23
+ default_indent = 4
24
+
25
+ def ready(self):
26
+ """
27
+ Register dbdiff.serializers.json and set debug.
28
+
29
+ Enables debug if a DBDIFF_DEBUG environment variable is found.
30
+
31
+ It is important to use serializers which dump data in a predictible way
32
+ because this app relies on diff between an expected - user-generated
33
+ and versioned - fixture and dumped database data. This method also
34
+ overrides the default json serializer with dbdiff's.
35
+
36
+ When dbdiff is installed, ``dumpdata`` will use its serializers which
37
+ have predictible output and cross-database support, so fixtures dumped
38
+ without dbdiff installed will have to be regenerated after dbdiff is
39
+ installed to be usable with dbdiff.
40
+
41
+ """
42
+ self.debug = os.environ.get('DBDIFF_DEBUG', False)
43
+ register_serializer('json', 'dbdiff.serializers.json')
44
+ patch_transaction_test_case()
dbdiff/exceptions.py ADDED
@@ -0,0 +1,72 @@
1
+ """Exceptions for dbdiff module."""
2
+ import pprint
3
+
4
+
5
+ class DbDiffException(Exception):
6
+ """Base exception for this app."""
7
+
8
+
9
+ class DiffFound(DbDiffException):
10
+ """Raised when a diff is found by the context manager."""
11
+
12
+ def _add_messages(self, msg, title, tree):
13
+ if tree:
14
+ for model, instances in tree.items():
15
+ msg.append(
16
+ title % (
17
+ len(instances),
18
+ model
19
+ )
20
+ )
21
+
22
+ for pk, fields in instances.items():
23
+ msg.append('#%s:\n%s' % (pk, pprint.pformat(fields)))
24
+
25
+ def __init__(self, fixture, unexpected, missing, diff):
26
+ """Exception for when a diff was found."""
27
+ msg = ['Diff found with dump at %s' % fixture.path]
28
+
29
+ self._add_messages(
30
+ msg,
31
+ '%s unexpected instance(s) of %s found in the dump:',
32
+ unexpected
33
+ )
34
+
35
+ self._add_messages(
36
+ msg,
37
+ '%s expected instance(s) of %s missing from dump:',
38
+ missing
39
+ )
40
+
41
+ if diff:
42
+ for model, instances in diff.items():
43
+ msg.append(
44
+ '%s instance(s) of %s have not expected fields' % (
45
+ len(instances),
46
+ model
47
+ )
48
+ )
49
+
50
+ for pk, fields in instances.items():
51
+ msg.append('#%s:' % pk)
52
+
53
+ for field, values in fields.items():
54
+ msg.append(' %s:' % field)
55
+ msg.append('- %s' % pprint.pformat(values[0]))
56
+ msg.append('+ %s' % pprint.pformat(values[1]))
57
+
58
+ super(DiffFound, self).__init__('\n'.join(msg))
59
+
60
+
61
+ class FixtureCreated(DbDiffException):
62
+ """
63
+ Raised when a fixture was created.
64
+
65
+ This purposely fails a test, to avoid misleading the user into thinking
66
+ that the test was properly executed against a versioned fixture. Imagine
67
+ one pushes a test without the fixture, it will break because of this
68
+ exception in CI.
69
+
70
+ However, this should only happen once per fixture - unless the user in
71
+ question forgets to commit the generated fixture !
72
+ """
dbdiff/fixture.py ADDED
@@ -0,0 +1,172 @@
1
+ """Public fixture API."""
2
+
3
+ import copy
4
+ import json
5
+ import os
6
+ import tempfile
7
+
8
+ from django.apps import apps
9
+ from django.core.management import call_command
10
+
11
+ import ijson
12
+
13
+ from .exceptions import DiffFound, FixtureCreated
14
+ from .utils import (
15
+ diff,
16
+ get_absolute_path,
17
+ get_model_names,
18
+ get_tree,
19
+ )
20
+
21
+
22
+ REWRITE = os.getenv('FIXTURE_REWRITE')
23
+
24
+
25
+ class Fixture(object):
26
+ """
27
+ Is able to print out diffs between database and a fixture.
28
+
29
+ .. py:attribute:: path
30
+
31
+ Absolute path to the fixture.
32
+
33
+ .. py:attribute:: models
34
+
35
+ List of models concerned by the fixture.
36
+
37
+ .. py:attribute:: database
38
+
39
+ Database name to use, 'default' by default.
40
+
41
+ .. py:attribute:: exclude
42
+
43
+ Class attribute, dict of model fields to ignore in the form of:
44
+ {'app.model': ['fieldN']}
45
+ """
46
+
47
+ exclude = dict()
48
+
49
+ def __init__(self, relative_path, models=None, database=None, ignore_pk=False):
50
+ """
51
+ Instanciate a FixtureDiff on a database.
52
+
53
+ relative_path is used to calculate :py:attr:`path`, with
54
+ :py:func:`~utils.get_absolute_path`.
55
+
56
+ If models is None, then it will be generated from reading the
57
+ fixture file, but generated fixtures will include all models.
58
+
59
+ database should be the name of the database to use, `default` by
60
+ default.
61
+
62
+ ignore_pk when True, matches records by field content instead of
63
+ primary key. Records with the same content but different pks
64
+ are considered equal.
65
+ """
66
+ self.path = get_absolute_path(relative_path)
67
+ self.models = models if models else self.parse_models()
68
+ self.database = database or 'default'
69
+ self.ignore_pk = ignore_pk
70
+
71
+ def parse_models(self):
72
+ """Return the list of models inside the fixture file."""
73
+ with open(self.path, 'r') as f:
74
+ return [apps.get_model(i.lower())
75
+ for i in ijson.items(f, 'item.model')]
76
+
77
+ @property
78
+ def exists(self):
79
+ """Return True if :py:attr:`path` exists."""
80
+ return os.path.exists(self.path)
81
+
82
+ @property
83
+ def indent(self):
84
+ """Return the indentation of the fixture file or the default indent."""
85
+ if not os.path.exists(self.path):
86
+ return apps.get_app_config('dbdiff').default_indent
87
+
88
+ with open(self.path, 'r') as f:
89
+ line = f.readline()
90
+
91
+ while line and ':' not in line:
92
+ line = f.readline()
93
+
94
+ if not line:
95
+ return apps.get_app_config('dbdiff').default_indent
96
+
97
+ return len(line) - len(line.lstrip(' '))
98
+
99
+ def diff(self, exclude=None, ignore_pk=None):
100
+ """
101
+ Diff the fixture against a datadump of fixture models.
102
+
103
+ If passed, exclude should be a list of field names to exclude from
104
+ being diff'ed.
105
+
106
+ ignore_pk when True, matches records by field content instead of
107
+ primary key. Defaults to the instance's ignore_pk attribute.
108
+ """
109
+ fh, dump_path = tempfile.mkstemp('_dbdiff')
110
+
111
+ exclude_final = copy.copy(self.exclude)
112
+ exclude_final.update(exclude or {})
113
+
114
+ with os.fdopen(fh, 'w') as f:
115
+ self.dump(f)
116
+
117
+ with open(self.path, 'r') as e, open(dump_path, 'r') as r:
118
+ expected, result = json.load(e), json.load(r)
119
+
120
+ if ignore_pk is None:
121
+ ignore_pk = self.ignore_pk
122
+
123
+ unexpected, missing, different = diff(
124
+ get_tree(expected, exclude_final),
125
+ get_tree(result, exclude_final),
126
+ ignore_pk=ignore_pk,
127
+ )
128
+
129
+ if not unexpected and not missing and not diff:
130
+ os.unlink(dump_path)
131
+ return None
132
+
133
+ return unexpected, missing, different
134
+
135
+ def load(self):
136
+ """Load fixture into the database."""
137
+ call_command('loaddata', self.path)
138
+
139
+ def dump(self, out):
140
+ """Write fixture with dumpdata for fixture models."""
141
+ call_command(
142
+ 'dumpdata',
143
+ *get_model_names(self.models),
144
+ format='json',
145
+ traceback=True,
146
+ indent=self.indent,
147
+ stdout=out,
148
+ use_natural_foreign_keys=True
149
+ )
150
+
151
+ def assertNoDiff(self, exclude=None): # noqa
152
+ """Assert that the fixture doesn't have any diff with the database.
153
+
154
+ If the fixture doesn't exist then it's written but
155
+ :py:class:`~exceptions.FixtureCreated` is raised.
156
+
157
+ If a diff was found it will raise :py:class:`~exceptions.DiffFound`.
158
+ """
159
+ if REWRITE or not self.exists:
160
+ with open(self.path, 'w+') as f:
161
+ self.dump(f)
162
+ if not REWRITE:
163
+ raise FixtureCreated(self)
164
+
165
+ unexpected, missing, different = self.diff(exclude=exclude)
166
+
167
+ if unexpected or missing or different:
168
+ raise DiffFound(self, unexpected, missing, different)
169
+
170
+ def __str__(self):
171
+ """Return :py:attr:`path` for string representation."""
172
+ return self.path
dbdiff/plugin.py ADDED
@@ -0,0 +1,35 @@
1
+ """Pytest plugin for django-dbdiff.
2
+
3
+ The marker enables the smarter sequence reset feature previously available in
4
+ the DbdiffTestMixin in pytest, example usage::
5
+
6
+ @dbdiff(models=[YourModel])
7
+ def your_test():
8
+ assert YourModel.objects.create().pk == 1
9
+ """
10
+ import pytest
11
+
12
+ from .sequence import sequence_reset
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def _dbdiff_marker(request):
17
+ marker = request.node.get_closest_marker('dbdiff')
18
+ if not marker:
19
+ return
20
+
21
+ # Enable transactional db
22
+ request.getfixturevalue('transactional_db')
23
+
24
+ for model in marker.kwargs['models']:
25
+ sequence_reset(model)
26
+
27
+
28
+ def pytest_load_initial_conftests(early_config, parser, args):
29
+ """Register the dbdiff mark."""
30
+ early_config.addinivalue_line(
31
+ 'markers',
32
+ 'dbdiff(models, reset_sequences=True): Mark the test as using '
33
+ 'the django test database. The *transaction* argument marks will '
34
+ "allow you to use real transactions in the test like Django's "
35
+ 'TransactionTestCase.')
dbdiff/sequence.py ADDED
@@ -0,0 +1,57 @@
1
+ """Smarter model pk sequence reset."""
2
+ from django.db import connection, models
3
+
4
+
5
+ def pk_sequence_get(model):
6
+ """Return a list of table, column tuples which should have sequences."""
7
+ for field in model._meta.get_fields():
8
+ if not getattr(field, 'primary_key', False):
9
+ continue
10
+ if not isinstance(field, models.AutoField):
11
+ continue
12
+ return (field.db_column or field.column, field.model._meta.db_table)
13
+ return (None, None)
14
+
15
+
16
+ def sequence_reset(model):
17
+ """
18
+ Better sequence reset than TransactionTestCase.
19
+
20
+ The difference with using TransactionTestCase with reset_sequences=True is
21
+ that this will reset sequences for the given models to their higher value,
22
+ supporting pre-existing models which could have been created by a
23
+ migration.
24
+ """
25
+ pk_field, table = pk_sequence_get(model)
26
+ if not pk_field:
27
+ return
28
+
29
+ if connection.vendor == 'postgresql':
30
+ reset = """
31
+ SELECT
32
+ setval(
33
+ pg_get_serial_sequence('{table}', '{column}'),
34
+ coalesce(max({column}),0) + 1,
35
+ false
36
+ )
37
+ FROM {table}
38
+ """
39
+ elif connection.vendor == 'sqlite':
40
+ reset = """
41
+ UPDATE sqlite_sequence
42
+ SET seq=(SELECT max({column}) from {table})
43
+ WHERE name='{table}'
44
+ """
45
+ elif connection.vendor == 'mysql':
46
+ cursor = connection.cursor()
47
+ cursor.execute(
48
+ 'SELECT MAX({column}) + 1 FROM {table}'.format(
49
+ column=pk_field, table=table
50
+ )
51
+ )
52
+ result = cursor.fetchone()[0] or 0
53
+ reset = 'ALTER TABLE {table} AUTO_INCREMENT = %s' % result
54
+
55
+ connection.cursor().execute(
56
+ reset.format(column=pk_field, table=table)
57
+ )
@@ -0,0 +1 @@
1
+ """Serializers with predictible (ordered) output."""
@@ -0,0 +1,80 @@
1
+ """Shared code for serializers."""
2
+
3
+ import collections
4
+ import datetime
5
+ import decimal
6
+
7
+
8
+ class BaseSerializerMixin(object):
9
+ """Serializer mixin for predictible and cross-db dumps."""
10
+
11
+ @classmethod
12
+ def recursive_dict_sort(cls, data):
13
+ """
14
+ Return a recursive OrderedDict for a dict.
15
+
16
+ Django's default model-to-dict logic - implemented in
17
+ django.core.serializers.python.Serializer.get_dump_object() - returns a
18
+ dict, this app registers a slightly modified version of the default
19
+ json serializer which returns OrderedDicts instead.
20
+ """
21
+ ordered_data = collections.OrderedDict(sorted(data.items()))
22
+
23
+ for key, value in ordered_data.items():
24
+ if isinstance(value, dict):
25
+ ordered_data[key] = cls.recursive_dict_sort(value)
26
+
27
+ return ordered_data
28
+
29
+ @classmethod
30
+ def remove_microseconds(cls, data):
31
+ """
32
+ Strip microseconds from datetimes for mysql.
33
+
34
+ MySQL doesn't have microseconds in datetimes, so dbdiff's serializer
35
+ removes microseconds from datetimes so that fixtures are cross-database
36
+ compatible which make them usable for cross-database testing.
37
+ """
38
+ for key, value in data['fields'].items():
39
+ if not isinstance(value, datetime.datetime):
40
+ continue
41
+
42
+ data['fields'][key] = datetime.datetime(
43
+ year=value.year,
44
+ month=value.month,
45
+ day=value.day,
46
+ hour=value.hour,
47
+ minute=value.minute,
48
+ second=value.second,
49
+ tzinfo=value.tzinfo
50
+ )
51
+
52
+ @classmethod
53
+ def normalize_decimals(cls, data):
54
+ """
55
+ Strip trailing zeros for constitency.
56
+
57
+ In addition, dbdiff serialization forces Decimal normalization, because
58
+ trailing zeros could happen in inconsistent ways.
59
+ """
60
+ for key, value in data['fields'].items():
61
+ if not isinstance(value, decimal.Decimal):
62
+ continue
63
+
64
+ if value % 1 == 0:
65
+ data['fields'][key] = int(value)
66
+ else:
67
+ data['fields'][key] = value.normalize()
68
+
69
+ def get_dump_object(self, obj):
70
+ """
71
+ Actual method used by Django serializers to dump dicts.
72
+
73
+ By overridding this method, we're able to run our various
74
+ data dump predictability methods.
75
+ """
76
+ data = super(BaseSerializerMixin, self).get_dump_object(obj)
77
+ self.remove_microseconds(data)
78
+ self.normalize_decimals(data)
79
+ data = self.recursive_dict_sort(data)
80
+ return data
@@ -0,0 +1,15 @@
1
+ """Django JSON Serializer override."""
2
+
3
+ from django.core.serializers import json as upstream
4
+
5
+ from .base import BaseSerializerMixin
6
+
7
+
8
+ __all__ = ('Serializer', 'Deserializer')
9
+
10
+
11
+ class Serializer(BaseSerializerMixin, upstream.Serializer):
12
+ """Sorted dict JSON serializer."""
13
+
14
+
15
+ Deserializer = upstream.Deserializer
dbdiff/test.py ADDED
@@ -0,0 +1,58 @@
1
+ """Convenience test mixin."""
2
+ from django.core.management import call_command
3
+
4
+ from .fixture import Fixture
5
+ from .sequence import sequence_reset
6
+
7
+
8
+ class DbdiffTestMixin(object):
9
+ """
10
+ Convenience mixin with better sequence resetting than TransactionTestCase.
11
+
12
+ The difference with using TransactionTestCase with reset_sequences=True is
13
+ that this will reset sequences for the given models to their higher value,
14
+ supporting pre-existing models which could have been created by a
15
+ migration.
16
+
17
+ The test case subclass requires some attributes and an implementation of a
18
+ ``dbdiff_test()`` method that does the actual import call that this
19
+ should test. Example usage::
20
+
21
+ class FrancedataImportTest(DbdiffTestMixin, test.TestCase):
22
+ dbdiff_models = [YourModel]
23
+ dbdiff_exclude = {'*': ['created']}
24
+ dbdiff_reset_sequences = True
25
+ dbdiff_expected = 'yourapp/tests/yourexpectedfixture.json'
26
+ dbdiff_fixtures = ['your-fixtures.json']
27
+
28
+ def dbdiff_test(self):
29
+ fixture = os.path.join(
30
+ os.path.dirname(__file__),
31
+ 'representatives_fixture.json'
32
+ )
33
+
34
+ with open(fixture, 'r') as f:
35
+ do_your_import.main(f)
36
+
37
+ Supports postgresql.
38
+ """
39
+
40
+ def test_db_import(self):
41
+ """Actual test method, ran by the test suite."""
42
+ call_command('flush', interactive=False)
43
+
44
+ for fixture in getattr(self, 'dbdiff_fixtures', []):
45
+ call_command('loaddata', fixture)
46
+
47
+ for model in self.dbdiff_models:
48
+ sequence_reset(model)
49
+
50
+ self.dbdiff_test()
51
+
52
+ Fixture(
53
+ self.dbdiff_expected,
54
+ models=self.dbdiff_models,
55
+ ignore_pk=getattr(self, 'dbdiff_ignore_pk', False),
56
+ ).assertNoDiff(
57
+ exclude=self.dbdiff_exclude,
58
+ )
@@ -0,0 +1 @@
1
+ """Tests for the dbdiff module."""
@@ -0,0 +1 @@
1
+ """App to test DecimalField dump predictibility."""
@@ -0,0 +1,17 @@
1
+ from django.db import models, migrations
2
+
3
+
4
+ class Migration(migrations.Migration):
5
+
6
+ dependencies = [
7
+ ]
8
+
9
+ operations = [
10
+ migrations.CreateModel(
11
+ name='TestModel',
12
+ fields=[
13
+ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
14
+ ('test_field', models.DecimalField(max_digits=3, decimal_places=3)),
15
+ ],
16
+ ),
17
+ ]
@@ -0,0 +1,16 @@
1
+ from django.db import models, migrations
2
+
3
+
4
+ class Migration(migrations.Migration):
5
+
6
+ dependencies = [
7
+ ('decimal_test', '0001_initial'),
8
+ ]
9
+
10
+ operations = [
11
+ migrations.AlterField(
12
+ model_name='testmodel',
13
+ name='test_field',
14
+ field=models.DecimalField(max_digits=5, decimal_places=2),
15
+ ),
16
+ ]
File without changes
@@ -0,0 +1,5 @@
1
+ from django.db import models
2
+
3
+
4
+ class TestModel(models.Model):
5
+ test_field = models.DecimalField(max_digits=5, decimal_places=2)
@@ -0,0 +1 @@
1
+ """Test app for model inheritance."""
@@ -0,0 +1,9 @@
1
+ from django.db import models
2
+
3
+
4
+ class Parent(models.Model):
5
+ pass
6
+
7
+
8
+ class Child(Parent):
9
+ name = models.CharField(max_length=50)
@@ -0,0 +1 @@
1
+ """Test that we don't crash with non sequence pks."""
@@ -0,0 +1,8 @@
1
+ import uuid
2
+
3
+ from django.db import models
4
+
5
+
6
+ class Nonintpk(models.Model):
7
+ # dbdiff should not try to reset this sequence
8
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -0,0 +1 @@
1
+ """Test project settings."""