From b3bcf1b5d8de7f736cedbcec69a8d962f307eaf7 Mon Sep 17 00:00:00 2001 From: Fabrizio Romano Date: Tue, 10 Nov 2015 14:57:18 +0000 Subject: [PATCH 01/12] PLAT-245 -- Add .gitignore --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d285a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.py[cod] +*.egg-info +.coverage +wheelhouse +config +.env.* +.cache +.tox From d3c5a3a861f82cd7dc29986e2aa1a71acd5d533f Mon Sep 17 00:00:00 2001 From: Fabrizio Romano Date: Fri, 13 Nov 2015 15:04:02 +0000 Subject: [PATCH 02/12] PLAT-245 -- Add setup.py and tox.ini --- setup.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tox.ini | 9 +++++++++ 2 files changed, 65 insertions(+) create mode 100644 setup.py create mode 100644 tox.ini diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e69a400 --- /dev/null +++ b/setup.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +from codecs import open +import os +from setuptools import setup, find_packages + + +here = os.path.abspath(os.path.dirname(__file__)) + + +with open(os.path.join(here, 'README.rst'), 'r', 'utf-8') as stream: + readme = stream.read() + + +setup( + name='sqlalchemy-diff', + version='0.0.1', + description='Compare two database schemas using sqlalchemy.', + long_description=readme, + author='student.com', + author_email='dark-side@student.com', # TODO - Add proper email here + url='https://github.com/Overseas-Student-Living/sqlalchemy-diff', + packages=find_packages(exclude=['docs', 'test', 'test.*']), + install_requires=[ + "six==1.10.0", + "mock==1.3.0", + "mysql-connector-python==2.0.4", + "sqlalchemy-utils==0.31.2", + ], + extras_require={ + 'dev': [ + "pytest==2.8.2", + ], + 'docs': [ + "Sphinx==1.3.1", + ], + }, + entry_points={ + 'pytest11': [ + 'sqlalchemy_diff=sqlalchemydiff.pyfixtures' + ] + }, + zip_safe=True, + license='Apache License, Version 2.0', + classifiers=[ + "Programming Language :: Python", + "Operating System :: Linux", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.4", + "Topic :: Internet", + "Topic :: Software Development :: Libraries :: Python Modules", + "Intended Audience :: Developers", + ] +) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..6faa3f1 --- /dev/null +++ b/tox.ini @@ -0,0 +1,9 @@ +[tox] +envlist = py27,py33,py34 +skipdist=True +skip_missing_interpreters=True + +[testenv] +commands = + pip install -e ".[dev]" --allow-external mysql-connector-python + py.test From cdc07acbd0ad0b247cbf43699c2cfe24df6b65ba Mon Sep 17 00:00:00 2001 From: Fabrizio Romano Date: Fri, 13 Nov 2015 15:04:20 +0000 Subject: [PATCH 03/12] PLAT-245 -- Add core of library. --- sqlalchemydiff/__init__.py | 6 + sqlalchemydiff/comparer.py | 343 +++++++++++++++++++++++++++++++++++ sqlalchemydiff/pyfixtures.py | 37 ++++ sqlalchemydiff/util.py | 127 +++++++++++++ 4 files changed, 513 insertions(+) create mode 100644 sqlalchemydiff/__init__.py create mode 100644 sqlalchemydiff/comparer.py create mode 100644 sqlalchemydiff/pyfixtures.py create mode 100644 sqlalchemydiff/util.py diff --git a/sqlalchemydiff/__init__.py b/sqlalchemydiff/__init__.py new file mode 100644 index 0000000..f146e21 --- /dev/null +++ b/sqlalchemydiff/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +from .comparer import compare + + +__all__ = ['compare'] diff --git a/sqlalchemydiff/comparer.py b/sqlalchemydiff/comparer.py new file mode 100644 index 0000000..57441c6 --- /dev/null +++ b/sqlalchemydiff/comparer.py @@ -0,0 +1,343 @@ +# -*- coding: utf-8 -*- +from copy import deepcopy + +from .util import TablesInfo, DiffResult, InspectorFactory, CompareResult + + +def compare(left_uri, right_uri, ignore_tables=None): + """Compare two databases, given two URIs. + + Compare two databases, given two URIs and a (possibly empty) set of + tables to ignore during the comparison. + + The ``info`` dict has this structure:: + + info = { + 'uris': { + 'left': 'left_uri', + 'right': 'right_uri', + }, + 'tables': { + 'left': 'tables_left', + 'left_only': 'tables_left_only', + 'right': 'tables_right', + 'right_only': 'tables_right_only', + 'common': ['table_name_1', 'table_name_2'], + }, + 'tables_data': { + + 'table_name_1': { + 'foreign_keys': { + 'left_only': [...], + 'right_only': [...], + 'common': [...], + 'diff': [...], + }, + 'primary_keys': { + 'left_only': [...], + 'right_only': [...], + 'common': [...], + 'diff': [...], + }, + 'indexes': { + 'left_only': [...], + 'right_only': [...], + 'common': [...], + 'diff': [...], + }, + 'columns': { + 'left_only': [...], + 'right_only': [...], + 'common': [...], + 'diff': [...], + } + }, + + 'table_name_2': { ... }, + } + } + + The ``errors`` dict will follow the same structure of the ``info`` + dict, but it will only have the data that is showing a discrepancy + between the two databases. + + :param string left_uri: The URI for the first (left) database. + :param string right_uri: The URI for the second (right) database. + :param set ignore_tables: + A set of string values to be excluded from both databases (if + present) when doing the comparison. String matching is case + sensitive. + :return: + A :class:`~.util.CompareResult` object with ``info`` and + ``errors`` dicts populated with the comparison result. + """ + if ignore_tables is None: + ignore_tables = set() + + left_inspector, right_inspector = _get_inspectors(left_uri, right_uri) + + tables_info = _get_tables_info( + left_inspector, right_inspector, ignore_tables) + + info = _get_info_dict(left_uri, right_uri, tables_info) + + info['tables_data'] = _get_tables_data( + tables_info.common, left_inspector, right_inspector) + + errors = _compile_errors(info) + result = _make_result(info, errors) + + return result + + +def _get_inspectors(left_uri, right_uri): + left_inspector = InspectorFactory.from_uri(left_uri) + right_inspector = InspectorFactory.from_uri(right_uri) + return left_inspector, right_inspector + + +def _get_tables_info(left_inspector, right_inspector, ignore_tables): + """Get information about the differences at the table level. """ + tables_left, tables_right = _get_tables( + left_inspector, right_inspector, ignore_tables) + + tables_left_only, tables_right_only = _get_tables_diff( + tables_left, tables_right) + + tables_common = _get_common_tables(tables_left, tables_right) + + return TablesInfo( + left=tables_left, right=tables_right, left_only=tables_left_only, + right_only=tables_right_only, common=tables_common) + + +def _get_tables(left_inspector, right_inspector, ignore_tables): + """Get table names for both databases. ``ignore_tables`` are removed. """ + tables_left = _get_tables_names(left_inspector, ignore_tables) + tables_right = _get_tables_names(right_inspector, ignore_tables) + return tables_left, tables_right + + +def _get_tables_names(inspector, ignore_tables): + return sorted(set(inspector.get_table_names()) - ignore_tables) + + +def _get_tables_diff(tables_left, tables_right): + return ( + _diff_table_lists(tables_left, tables_right), + _diff_table_lists(tables_right, tables_left) + ) + + +def _diff_table_lists(tables_left, tables_right): + return sorted(set(tables_left) - set(tables_right)) + + +def _get_common_tables(tables_left, tables_right): + return sorted(set(tables_left) & set(tables_right)) + + +def _get_info_dict(left_uri, right_uri, tables_info): + """Create an empty stub for the `info` dict. """ + info = { + 'uris': { + 'left': left_uri, + 'right': right_uri, + }, + 'tables': { + 'left': tables_info.left, + 'left_only': tables_info.left_only, + 'right': tables_info.right, + 'right_only': tables_info.right_only, + 'common': tables_info.common, + }, + 'tables_data': {}, + } + + return info + + +def _get_tables_data(tables_common, left_inspector, right_inspector): + tables_data = {} + + for table_name in tables_common: + table_data = _get_table_data( + left_inspector, right_inspector, table_name) + tables_data[table_name] = table_data + + return tables_data + + +def _get_table_data(left_inspector, right_inspector, table_name): + table_data = {} + + # foreign keys + table_data['foreign_keys'] = _get_foreign_keys_info( + left_inspector, right_inspector, table_name) + + table_data['primary_keys'] = _get_primary_keys_info( + left_inspector, right_inspector, table_name) + + table_data['indexes'] = _get_indexes_info( + left_inspector, right_inspector, table_name) + + table_data['columns'] = _get_columns_info( + left_inspector, right_inspector, table_name) + + return table_data + + +def _diff_dicts(left, right): + """Makes the diff of two dictionaries, based on keys and values. + + :return: + A 4-tuple with elements:: + + * A list of elements only in left + * A list of elements only in right + * A list of common elements + * A list of diff elements + {'key':..., 'left':..., 'right':...} + """ + left_only_key = set(left) - set(right) + right_only_key = set(right) - set(left) + + left_only = [left[key] for key in left_only_key] + right_only = [right[key] for key in right_only_key] + + # common and diff + common_keys = set(left) & set(right) + common = [] + diff = [] + + for key in common_keys: + if left[key] == right[key]: + common.append(left[key]) + else: + diff.append({ + 'key': key, + 'left': left[key], + 'right': right[key], + }) + + return DiffResult( + left_only=left_only, right_only=right_only, common=common, diff=diff + )._asdict() + + +def _get_foreign_keys_info(left_inspector, right_inspector, table_name): + left_fk_list = _get_foreign_keys(left_inspector, table_name) + right_fk_list = _get_foreign_keys(right_inspector, table_name) + + # process into dict + left_fk = dict((elem['name'], elem) for elem in left_fk_list) + right_fk = dict((elem['name'], elem) for elem in right_fk_list) + + return _diff_dicts(left_fk, right_fk) + + +def _get_foreign_keys(inspector, table_name): + return inspector.get_foreign_keys(table_name) + + +def _get_primary_keys_info(left_inspector, right_inspector, table_name): + left_pk_list = _get_primary_keys(left_inspector, table_name) + right_pk_list = _get_primary_keys(right_inspector, table_name) + + # process into dict + left_pk = dict((elem, elem) for elem in left_pk_list) + right_pk = dict((elem, elem) for elem in right_pk_list) + + return _diff_dicts(left_pk, right_pk) + + +def _get_primary_keys(inspector, table_name): + return inspector.get_primary_keys(table_name) + + +def _get_indexes_info(left_inspector, right_inspector, table_name): + left_index_list = _get_indexes(left_inspector, table_name) + right_index_list = _get_indexes(right_inspector, table_name) + + # process into dict + left_index = dict((elem['name'], elem) for elem in left_index_list) + right_index = dict((elem['name'], elem) for elem in right_index_list) + + return _diff_dicts(left_index, right_index) + + +def _get_indexes(inspector, table_name): + return inspector.get_indexes(table_name) + + +def _get_columns_info(left_inspector, right_inspector, table_name): + left_columns_list = _get_columns(left_inspector, table_name) + right_columns_list = _get_columns(right_inspector, table_name) + + # process into dict + left_columns = dict((elem['name'], elem) for elem in left_columns_list) + right_columns = dict((elem['name'], elem) for elem in right_columns_list) + + # process `type` fields + _process_types(left_columns) + _process_types(right_columns) + + return _diff_dicts(left_columns, right_columns) + + +def _get_columns(inspector, table_name): + return inspector.get_columns(table_name) + + +def _process_types(column_dict): + for column in column_dict: + column_dict[column]['type'] = _process_type( + column_dict[column]['type']) + + +def _process_type(type_): + """Process the SQLAlchemy Column Type ``type_``. + + Calls :meth:`sqlalchemy.sql.type_api.TypeEngine.compile` on + ``type_`` to produce a string-compiled form of it. "string-compiled" + meaning as it would be used for a SQL clause. + """ + return type_.compile() + + +def _compile_errors(info): + """Create ``errors`` dict from ``info`` dict. """ + errors_template = { + 'tables': {}, + 'tables_data': {}, + } + errors = deepcopy(errors_template) + + # first check if tables aren't a match + if info['tables']['left_only']: + errors['tables']['left_only'] = info['tables']['left_only'] + + if info['tables']['right_only']: + errors['tables']['right_only'] = info['tables']['right_only'] + + # then check if there is a discrepancy in the data for each table + keys = ['foreign_keys', 'primary_keys', 'indexes', 'columns'] + subkeys = ['left_only', 'right_only', 'diff'] + + for table_name in info['tables_data']: + for key in keys: + for subkey in subkeys: + if info['tables_data'][table_name][key][subkey]: + table_d = errors['tables_data'].setdefault(table_name, {}) + table_d.setdefault(key, {})[subkey] = info[ + 'tables_data'][table_name][key][subkey] + + if errors != errors_template: + errors['uris'] = info['uris'] + return errors + return {} + + +def _make_result(info, errors): + """Create a :class:`~.util.CompareResult` object. """ + return CompareResult(info, errors) diff --git a/sqlalchemydiff/pyfixtures.py b/sqlalchemydiff/pyfixtures.py new file mode 100644 index 0000000..48ba6f5 --- /dev/null +++ b/sqlalchemydiff/pyfixtures.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +import pytest + +from .util import ( + get_temporary_uri, + new_db, + destroy_database, +) + + +@pytest.fixture(scope="module") +def db_uri(): + return "mysql+mysqlconnector://root:@localhost/sqlalchemydiff" + + +@pytest.fixture(scope="module") +def uri_left(db_uri): + return get_temporary_uri(db_uri) + + +@pytest.fixture(scope="module") +def uri_right(db_uri): + return get_temporary_uri(db_uri) + + +@pytest.yield_fixture +def new_db_left(uri_left): + new_db(uri_left) + yield + destroy_database(uri_left) + + +@pytest.yield_fixture +def new_db_right(uri_right): + new_db(uri_right) + yield + destroy_database(uri_right) \ No newline at end of file diff --git a/sqlalchemydiff/util.py b/sqlalchemydiff/util.py new file mode 100644 index 0000000..fb7204c --- /dev/null +++ b/sqlalchemydiff/util.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +from collections import namedtuple +from uuid import uuid4 +import json + +from sqlalchemy import inspect, create_engine +from sqlalchemy_utils import create_database, drop_database, database_exists + + +TablesInfo = namedtuple( + 'TablesInfo', ['left', 'right', 'left_only', 'right_only', 'common']) +"""Represent information about the tables in a comparison between two +databases. It's meant for internal use. """ + + +DiffResult = namedtuple( + 'DiffResult', ['left_only', 'right_only', 'common', 'diff']) +"""Represent information about table properties in a comparison between +tables from two databases. It's meant for internal use. """ + + +class InspectorFactory(object): + + """Create a :func:`sqlalchemy.inspect` instance for a given URI. """ + + @classmethod + def from_uri(cls, uri): + engine = create_engine(uri) + inspector = inspect(engine) + return inspector + + +class CompareResult(object): + + """Represent the result of a comparison. + + It tells if the comparison was a match, and it allows the user to + dump both the `info` and `errors` dicts to a file in JSON format, + so that they can be inspected. + """ + + def __init__(self, info, errors): + self.info = info + self.errors = errors + + @property + def is_match(self): + """Tell if comparison was a match. """ + return not self.errors + + def dump_info(self, filename='info_dump.json'): + """Dump `info` dict to a file. """ + return self._dump(self.info, filename) + + def dump_errors(self, filename='errors_dump.json'): + """Dump `errors` dict to a file. """ + return self._dump(self.errors, filename) + + def _dump(self, data_to_dump, filename): + data = self._dump_data(data_to_dump) + if filename is not None: + self._write_data_to_file(data, filename) + return data + + def _dump_data(self, data): + return json.dumps(data, indent=4, sort_keys=True) + + def _write_data_to_file(self, data, filename): + with open(filename, 'w') as stream: + stream.write(data) + + +def new_db(uri): + """Drop the database at ``uri`` and create a brand new one. """ + destroy_database(uri) + create_database(uri) + + +def destroy_database(uri): + """Destroy the database at ``uri``, if it exists. """ + if database_exists(uri): + drop_database(uri) + + +def get_temporary_uri(uri): + """Substitutes the database name with a random one. + + For example, given this uri: + "mysql+mysqlconnector://root:@localhost/database_name" + + a call to ``get_temporary_uri(uri)`` could return something like this: + "mysql+mysqlconnector://root:@localhost/temp_000da...898fe" + + where the last part of the name is taken from a unique ID in hex + format. + """ + base, _ = uri.rsplit('/', 1) + uri = '{}/temp_{}'.format(base, uuid4().hex) + return uri + + +def prepare_schema_from_models(uri, sqlalchemy_base): + """Creates the database schema from the ``SQLAlchemy`` models. """ + engine = create_engine(uri) + sqlalchemy_base.metadata.create_all(engine) + + +def walk_dict(d, path): + """Walks a dict given a path of keys. + + For example, if we have a dict like this:: + + d = { + 'a': { + 'B': { + 1: ['hello', 'world'], + 2: ['hello', 'again'], + } + } + } + + Then ``walk_dict(d, ['a', 'B', 1])`` would return + ``['hello', 'world']``. + """ + if not path: + return d + return walk_dict(d[path[0]], path[1:]) From c1267ba2ac745e7a5ba0a012ce9406b8982da7b3 Mon Sep 17 00:00:00 2001 From: Fabrizio Romano Date: Fri, 13 Nov 2015 15:04:32 +0000 Subject: [PATCH 04/12] PLAT-245 -- Add library tests. --- test/__init__.py | 17 + test/unit/test_comparer.py | 736 +++++++++++++++++++++++++++++++++++++ test/unit/test_util.py | 75 ++++ 3 files changed, 828 insertions(+) create mode 100644 test/__init__.py create mode 100644 test/unit/test_comparer.py create mode 100644 test/unit/test_util.py diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..a4ce8f6 --- /dev/null +++ b/test/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from unittest import TestCase + +import six + + +if six.PY2: + + class TestCasePatch(TestCase): + """Provide the assert_items_equal method for testing. """ + def runTest(self, *a, **kwa): # Hack needed only in Python 2 + pass + assert_items_equal = TestCasePatch().assertItemsEqual + +else: + + assert_items_equal = TestCase().assertCountEqual diff --git a/test/unit/test_comparer.py b/test/unit/test_comparer.py new file mode 100644 index 0000000..a13cbb6 --- /dev/null +++ b/test/unit/test_comparer.py @@ -0,0 +1,736 @@ +# -*- coding: utf-8 -*- +import pytest + +from mock import Mock, patch, call + +from sqlalchemydiff.comparer import ( + _compile_errors, + _diff_dicts, + _get_columns, + _get_columns_info, + _get_common_tables, + _get_foreign_keys, + _get_foreign_keys_info, + _get_indexes, + _get_indexes_info, + _get_info_dict, + _get_inspectors, + _get_primary_keys, + _get_primary_keys_info, + _get_table_data, + _get_tables, + _get_tables_data, + _get_tables_diff, + _get_tables_info, + _make_result, + _process_type, + _process_types, + compare, + CompareResult, + InspectorFactory, + TablesInfo, +) +from test import assert_items_equal + + +@pytest.yield_fixture +def mock_inspector_factory(): + with patch.object(InspectorFactory, 'from_uri') as from_uri: + from_uri.side_effect = [ + Mock(name="Left Inspector From Factory"), + Mock(name="Right Inspector From Factory") + ] + yield + + +@pytest.mark.usefixtures("mock_inspector_factory") +class TestCompareCallsChain(object): + """This test class makes sure the `compare` function inside process + works as expected. + """ + @pytest.yield_fixture + def _get_inspectors_mock(self): + with patch('sqlalchemydiff.comparer._get_inspectors') as m: + m.return_value = [ + Mock(name="Left Inspector"), + Mock(name="Right Inspector"), + ] + yield m + + @pytest.yield_fixture + def _get_tables_data_mock(self): + with patch('sqlalchemydiff.comparer._get_tables_data') as m: + yield m + + @pytest.yield_fixture + def _compile_errors_mock(self): + with patch('sqlalchemydiff.comparer._compile_errors') as m: + + def info_side_effect(info): + """Using this side effect is enough to verify that we + pass the final version of `info` to the `calculate_errors` + function, and that the function actually does something, + which in the mocked version is adding the '_err' key/val. + """ + errors = info.copy() + errors['_err'] = True + return errors + + m.side_effect = info_side_effect + yield m + + @pytest.yield_fixture + def _get_tables_info_mock(self): + with patch('sqlalchemydiff.comparer._get_tables_info') as m: + m.return_value = TablesInfo( + left=Mock(name="Tables Left"), + right=Mock(name="Tables Right"), + left_only=Mock(name="Tables Only Left"), + right_only=Mock(name="Tables Only Right"), + common=['common_table_A', 'common_table_B'], + ) + yield m + + def test_compare_calls_chain( + self, _get_tables_info_mock, _get_tables_data_mock, + _compile_errors_mock): + """By inspecting `info` and `errors` at the end, we automatically + check that the whole process works as expected. What this test + leaves out is the verifications about inspectors. + """ + _get_tables_data_mock.return_value = { + 'common_table_A': { + 'data': 'some-data-A', + }, + 'common_table_B': { + 'data': 'some-data-B', + }, + } + + tables_info = _get_tables_info_mock.return_value + + result = compare( + "left_uri", "right_uri", ignore_tables=set(['ignore_me'])) + + expected_info = { + 'uris': { + 'left': "left_uri", + 'right': "right_uri", + }, + 'tables': { + 'left': tables_info.left, + 'left_only': tables_info.left_only, + 'right': tables_info.right, + 'right_only': tables_info.right_only, + 'common': tables_info.common, + }, + 'tables_data': { + 'common_table_A': { + 'data': 'some-data-A', + }, + 'common_table_B': { + 'data': 'some-data-B', + }, + }, + } + + expected_errors = expected_info.copy() + expected_errors['_err'] = True + + assert expected_info == result.info + assert expected_errors == result.errors + + def test__get_tables_info_called_with_correct_inspectors( + self, _get_inspectors_mock, _get_tables_info_mock, + _get_tables_data_mock, _compile_errors_mock): + left_inspector, right_inspector = _get_inspectors_mock.return_value + + compare("left_uri", "right_uri", ignore_tables=set(['ignore_me'])) + + _get_inspectors_mock.assert_called_once_with("left_uri", "right_uri") + _get_tables_info_mock.assert_called_once_with( + left_inspector, right_inspector, set(['ignore_me'])) + + +@pytest.mark.usefixtures("mock_inspector_factory") +class TestCompareInternals(object): + + ## FIXTURES + + @pytest.yield_fixture + def _get_table_data_mock(self): + with patch('sqlalchemydiff.comparer._get_table_data') as m: + yield m + + @pytest.yield_fixture + def _diff_dicts_mock(self): + with patch('sqlalchemydiff.comparer._diff_dicts') as m: + yield m + + @pytest.yield_fixture + def _get_foreign_keys_mock(self): + with patch('sqlalchemydiff.comparer._get_foreign_keys') as m: + yield m + + @pytest.yield_fixture + def _get_primary_keys_mock(self): + with patch('sqlalchemydiff.comparer._get_primary_keys') as m: + yield m + + @pytest.yield_fixture + def _get_indexes_mock(self): + with patch('sqlalchemydiff.comparer._get_indexes') as m: + yield m + + @pytest.yield_fixture + def _get_columns_mock(self): + with patch('sqlalchemydiff.comparer._get_columns') as m: + yield m + + @pytest.yield_fixture + def _process_types_mock(self): + with patch('sqlalchemydiff.comparer._process_types') as m: + yield m + + @pytest.yield_fixture + def _process_type_mock(self): + with patch('sqlalchemydiff.comparer._process_type') as m: + yield m + + @pytest.yield_fixture + def _get_foreign_keys_info_mock(self): + with patch('sqlalchemydiff.comparer._get_foreign_keys_info') as m: + yield m + + @pytest.yield_fixture + def _get_primary_keys_info_mock(self): + with patch('sqlalchemydiff.comparer._get_primary_keys_info') as m: + yield m + + @pytest.yield_fixture + def _get_indexes_info_mock(self): + with patch('sqlalchemydiff.comparer._get_indexes_info') as m: + yield m + + @pytest.yield_fixture + def _get_columns_info_mock(self): + with patch('sqlalchemydiff.comparer._get_columns_info') as m: + yield m + + ## TESTS + + def test__get_inspectors(self): + left_inspector_mock, right_inspector_mock = Mock(), Mock() + InspectorFactory.from_uri.side_effect = [ + left_inspector_mock, right_inspector_mock] + left_inspector, right_inspector = _get_inspectors( + "left_uri", "right_uri") + + assert ( + [call("left_uri"), call("right_uri")] == + InspectorFactory.from_uri.call_args_list + ) + + assert left_inspector_mock == left_inspector + assert right_inspector_mock == right_inspector + + def test__get_tables(self): + left_inspector, right_inspector = Mock(), Mock() + left_inspector.get_table_names.return_value = ['B', 'ignore_me', 'A'] + right_inspector.get_table_names.return_value = ['C', 'D', 'ignore_me'] + + tables_left, tables_right = _get_tables( + left_inspector, right_inspector, set(['ignore_me']) + ) + + assert ['A', 'B'] == tables_left + assert ['C', 'D'] == tables_right + + def test__get_tables_diff(self): + tables_left = ['B', 'A', 'Z', 'C'] + tables_right = ['D', 'Z', 'C', 'F'] + + tables_left_only, tables_right_only = _get_tables_diff( + tables_left, tables_right) + + assert ['A', 'B'] == tables_left_only + assert ['D', 'F'] == tables_right_only + + def test__get_common_tables(self): + tables_left = ['B', 'A', 'Z', 'C'] + tables_right = ['D', 'Z', 'C', 'F'] + + tables_common = _get_common_tables(tables_left, tables_right) + + assert ['C', 'Z'] == tables_common + + def test__get_tables_info(self): + left_inspector, right_inspector = Mock(), Mock() + left_inspector.get_table_names.return_value = [ + 'B', 'ignore_me', 'A', 'C'] + right_inspector.get_table_names.return_value = [ + 'D', 'C', 'ignore_me', 'Z'] + + tables_info = _get_tables_info( + left_inspector, right_inspector, set(['ignore_me'])) + + assert ['A', 'B', 'C'] == tables_info.left + assert ['C', 'D', 'Z'] == tables_info.right + assert ['A', 'B'] == tables_info.left_only + assert ['D', 'Z'] == tables_info.right_only + assert ['C'] == tables_info.common + + def test__get_info_dict(self): + tables_info = TablesInfo( + left=['A', 'B', 'C'], right=['C', 'D', 'Z'], + left_only=['A', 'B'], right_only=['D', 'Z'], common=['C']) + + info = _get_info_dict('left_uri', 'right_uri', tables_info) + + expected_info = { + 'uris': { + 'left': 'left_uri', + 'right': 'right_uri', + }, + 'tables': { + 'left': ['A', 'B', 'C'], + 'left_only': ['A', 'B'], + 'right': ['C', 'D', 'Z'], + 'right_only': ['D', 'Z'], + 'common': ['C'], + }, + 'tables_data': {}, + } + + assert expected_info == info + + def test__get_tables_data(self, _get_table_data_mock): + _get_table_data_mock.side_effect = [ + {'table_data': 'data_A'}, + {'table_data': 'data_B'}, + ] + left_inspector, right_inspector = Mock(), Mock() + tables_common = ['common_table_A', 'common_table_B'] + + tables_data = _get_tables_data( + tables_common, left_inspector, right_inspector) + + expected_tables_data = { + 'common_table_A': {'table_data': 'data_A'}, + 'common_table_B': {'table_data': 'data_B'}, + } + + assert expected_tables_data == tables_data + + def test__make_result(self): + info = {'info': 'dict'} + errors = {'errors': 'dict'} + + result = _make_result(info, errors) + + assert isinstance(result, CompareResult) + assert info == result.info + assert errors == result.errors + + def test__diff_dicts(self): + left = { + 'a': 'value-a', + 'b': 'value-b-left', + 'c': 'value-common', + } + + right = { + 'b': 'value-b-right', + 'c': 'value-common', + 'd': 'value-d', + } + + expected_result = { + 'left_only': ['value-a'], + 'right_only': ['value-d'], + 'common': ['value-common'], + 'diff': [ + {'key': 'b', + 'left': 'value-b-left', + 'right': 'value-b-right'} + ], + } + + result = _diff_dicts(left, right) + + assert expected_result == result + + def test__get_foreign_keys_info( + self, _diff_dicts_mock, _get_foreign_keys_mock): + _get_foreign_keys_mock.side_effect = [ + [{'name': 'fk_left_1'}, {'name': 'fk_left_2'}], + [{'name': 'fk_right_1'}] + ] + left_inspector, right_inspector = Mock(), Mock() + + result = _get_foreign_keys_info( + left_inspector, right_inspector, 'table_A') + + _diff_dicts_mock.assert_called_once_with( + { + 'fk_left_1': {'name': 'fk_left_1'}, + 'fk_left_2': {'name': 'fk_left_2'} + }, + { + 'fk_right_1': {'name': 'fk_right_1'} + } + ) + + assert _diff_dicts_mock.return_value == result + + def test__get_foreign_keys(self): + inspector = Mock() + + result = _get_foreign_keys(inspector, 'table_A') + + inspector.get_foreign_keys.assert_called_once_with('table_A') + assert inspector.get_foreign_keys.return_value == result + + def test__get_primary_keys_info( + self, _diff_dicts_mock, _get_primary_keys_mock): + _get_primary_keys_mock.side_effect = [ + ['pk_left_1', 'pk_left_2'], + ['pk_right_1'] + ] + left_inspector, right_inspector = Mock(), Mock() + + result = _get_primary_keys_info( + left_inspector, right_inspector, 'table_A') + + _diff_dicts_mock.assert_called_once_with( + {'pk_left_1': 'pk_left_1', 'pk_left_2': 'pk_left_2'}, + {'pk_right_1': 'pk_right_1'} + ) + + assert _diff_dicts_mock.return_value == result + + def test__get_primary_keys(self): + inspector = Mock() + + result = _get_primary_keys(inspector, 'table_A') + + inspector.get_primary_keys.assert_called_once_with('table_A') + assert inspector.get_primary_keys.return_value == result + + def test__get_indexes_info( + self, _diff_dicts_mock, _get_indexes_mock): + _get_indexes_mock.side_effect = [ + [{'name': 'index_left_1'}, {'name': 'index_left_2'}], + [{'name': 'index_right_1'}] + ] + left_inspector, right_inspector = Mock(), Mock() + + result = _get_indexes_info( + left_inspector, right_inspector, 'table_A') + + _diff_dicts_mock.assert_called_once_with( + { + 'index_left_1': {'name': 'index_left_1'}, + 'index_left_2': {'name': 'index_left_2'} + }, + { + 'index_right_1': {'name': 'index_right_1'} + } + ) + + assert _diff_dicts_mock.return_value == result + + def test__get_indexes(self): + inspector = Mock() + + result = _get_indexes(inspector, 'table_A') + + inspector.get_indexes.assert_called_once_with('table_A') + assert inspector.get_indexes.return_value == result + + def test__get_columns_info( + self, _diff_dicts_mock, _get_columns_mock, _process_types_mock): + _get_columns_mock.side_effect = [ + [{'name': 'columns_left_1'}, {'name': 'columns_left_2'}], + [{'name': 'columns_right_1'}] + ] + + def process_types_side_effect(columns): + columns['_processed'] = True + _process_types_mock.side_effect = process_types_side_effect + + left_inspector, right_inspector = Mock(), Mock() + + result = _get_columns_info( + left_inspector, right_inspector, 'table_A') + + _diff_dicts_mock.assert_called_once_with( + { + '_processed': True, + 'columns_left_1': {'name': 'columns_left_1'}, + 'columns_left_2': {'name': 'columns_left_2'} + }, + { + '_processed': True, + 'columns_right_1': {'name': 'columns_right_1'} + } + ) + + assert _diff_dicts_mock.return_value == result + + def test__get_columns(self): + inspector = Mock() + + result = _get_columns(inspector, 'table_A') + + inspector.get_columns.assert_called_once_with('table_A') + assert inspector.get_columns.return_value == result + + def test__process_types(self, _process_type_mock): + column_dict = { + 'columns_left_1': {'name': 'columns_left_1', 'type': 'type1'}, + 'columns_left_2': {'name': 'columns_left_2', 'type': 'type2'} + } + + _process_types(column_dict) + + assert_items_equal( + [call('type1'), call('type2')], + _process_type_mock.call_args_list) + + def test_process_type(self): + type_ = Mock() + result = _process_type(type_) + + type_.compile.assert_called_once_with() + assert type_.compile.return_value == result + + def test__get_table_data( + self, _get_foreign_keys_info_mock, _get_primary_keys_info_mock, + _get_indexes_info_mock, _get_columns_info_mock): + left_inspector, right_inspector = Mock(), Mock() + + _get_foreign_keys_info_mock.return_value = { + 'left_only': 1, 'right_only': 2, 'common': 3, 'diff': 4 + } + _get_primary_keys_info_mock.return_value = { + 'left_only': 5, 'right_only': 6, 'common': 7, 'diff': 8 + } + _get_indexes_info_mock.return_value = { + 'left_only': 9, 'right_only': 10, 'common': 11, 'diff': 12 + } + _get_columns_info_mock.return_value = { + 'left_only': 13, 'right_only': 14, 'common': 15, 'diff': 16 + } + + result = _get_table_data(left_inspector, right_inspector, 'table_A') + + expected_result = { + 'foreign_keys': { + 'left_only': 1, + 'right_only': 2, + 'common': 3, + 'diff': 4, + }, + 'primary_keys': { + 'left_only': 5, + 'right_only': 6, + 'common': 7, + 'diff': 8, + }, + 'indexes': { + 'left_only': 9, + 'right_only': 10, + 'common': 11, + 'diff': 12, + }, + 'columns': { + 'left_only': 13, + 'right_only': 14, + 'common': 15, + 'diff': 16, + }, + } + + assert expected_result == result + + def test__compile_errors_with_errors(self): + info = { + 'uris': { + 'left': 'left_uri', + 'right': 'right_uri', + }, + 'tables': { + 'left': 'tables_left', + 'left_only': 'tables_left_only', + 'right': 'tables_right', + 'right_only': 'tables_right_only', + 'common': 'tables_common', + }, + 'tables_data': { + + 'table_name_1': { + 'foreign_keys': { + 'left_only': 1, + 'right_only': 2, + 'common': 3, + 'diff': 4, + }, + 'primary_keys': { + 'left_only': 5, + 'right_only': 6, + 'common': 7, + 'diff': 8, + }, + 'indexes': { + 'left_only': 9, + 'right_only': 10, + 'common': 11, + 'diff': 12, + }, + 'columns': { + 'left_only': 13, + 'right_only': 14, + 'common': 15, + 'diff': 16, + } + }, + + 'table_name_2': { + 'foreign_keys': { + 'left_only': 1, + 'right_only': 2, + 'common': 3, + 'diff': 4, + }, + 'primary_keys': { + 'left_only': 5, + 'right_only': 6, + 'common': 7, + 'diff': 8, + }, + 'indexes': { + 'left_only': 9, + 'right_only': 10, + 'common': 11, + 'diff': 12, + }, + 'columns': { + 'left_only': 13, + 'right_only': 14, + 'common': 15, + 'diff': 16, + } + } + } + } + + expected_errors = { + 'uris': { + 'left': 'left_uri', + 'right': 'right_uri', + }, + 'tables': { + 'left_only': 'tables_left_only', + 'right_only': 'tables_right_only', + }, + 'tables_data': { + 'table_name_1': { + 'foreign_keys': { + 'left_only': 1, + 'right_only': 2, + 'diff': 4, + }, + 'primary_keys': { + 'left_only': 5, + 'right_only': 6, + 'diff': 8, + }, + 'indexes': { + 'left_only': 9, + 'right_only': 10, + 'diff': 12, + }, + 'columns': { + 'left_only': 13, + 'right_only': 14, + 'diff': 16, + } + }, + + 'table_name_2': { + 'foreign_keys': { + 'left_only': 1, + 'right_only': 2, + 'diff': 4, + }, + 'primary_keys': { + 'left_only': 5, + 'right_only': 6, + 'diff': 8, + }, + 'indexes': { + 'left_only': 9, + 'right_only': 10, + 'diff': 12, + }, + 'columns': { + 'left_only': 13, + 'right_only': 14, + 'diff': 16, + } + } + } + } + + errors = _compile_errors(info) + + assert expected_errors == errors + + def test__compile_errors_without_errors(self): + info = { + 'uris': { + 'left': 'left_uri', + 'right': 'right_uri', + }, + 'tables': { + 'left': 'tables_left', + 'left_only': [], + 'right': 'tables_right', + 'right_only': [], + 'common': 'tables_common', + }, + 'tables_data': { + 'table_name_1': { + 'foreign_keys': { + 'left_only': [], + 'right_only': [], + 'common': 1, + 'diff': [], + }, + 'primary_keys': { + 'left_only': [], + 'right_only': [], + 'common': 2, + 'diff': [], + }, + 'indexes': { + 'left_only': [], + 'right_only': [], + 'common': 3, + 'diff': [], + }, + 'columns': { + 'left_only': [], + 'right_only': [], + 'common': 4, + 'diff': [], + }, + } + } + } + + expected_errors = {} + errors = _compile_errors(info) + + assert expected_errors == errors diff --git a/test/unit/test_util.py b/test/unit/test_util.py new file mode 100644 index 0000000..69d2caf --- /dev/null +++ b/test/unit/test_util.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +import json +import os +import uuid + +import pytest + +from sqlalchemydiff.util import CompareResult, InspectorFactory +from mock import Mock, patch, call + + +class TestCompareResult(object): + + def test___init__(self): + info, errors = Mock(), Mock() + + result = CompareResult(info, errors) + + assert info == result.info + assert errors == result.errors + + def test_is_match(self): + info, errors = {}, {} + result = CompareResult(info, errors) + + assert True == result.is_match + + result.errors = {1: 1} + assert False == result.is_match + + def test_dump_info(self): + info = {'some': 'info'} + filename = '{}.txt'.format(uuid.uuid4()) + result = CompareResult(info, {}) + + result.dump_info(filename=filename) + + with open(filename, 'rU') as stream: + assert info == json.loads(stream.read()) + + os.unlink(filename) + + def test_dump_errors(self): + errors = {'some': 'errors'} + filename = '{}.txt'.format(uuid.uuid4()) + result = CompareResult({}, errors) + + result.dump_errors(filename=filename) + + with open(filename, 'rU') as stream: + assert errors == json.loads(stream.read()) + + os.unlink(filename) + + +class TestInspectorFactory(object): + + @pytest.yield_fixture + def create_engine_mock(self): + with patch('sqlalchemydiff.util.create_engine') as m: + yield m + + @pytest.yield_fixture + def inspect_mock(self): + with patch('sqlalchemydiff.util.inspect') as m: + yield m + + def test_from_uri(self, inspect_mock, create_engine_mock): + uri = 'some-db-uri/some-db-name' + inspector = InspectorFactory.from_uri(uri) + + create_engine_mock.assert_called_once_with(uri) + inspect_mock.assert_called_once_with(create_engine_mock.return_value) + + assert inspect_mock.return_value == inspector From 1d199ee4e4359021de90927fb9eb15be92506330 Mon Sep 17 00:00:00 2001 From: Fabrizio Romano Date: Fri, 13 Nov 2015 15:04:54 +0000 Subject: [PATCH 05/12] PLAT-245 -- Add Sphinx documentation stub. --- docs/Makefile | 192 ++++++++++++++++++++++ docs/make.bat | 263 ++++++++++++++++++++++++++++++ docs/source/conf.py | 364 ++++++++++++++++++++++++++++++++++++++++++ docs/source/index.rst | 0 4 files changed, 819 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..c09834a --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,192 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/SqlAlchemyDiff.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/SqlAlchemyDiff.qhc" + +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/SqlAlchemyDiff" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/SqlAlchemyDiff" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..4d01c77 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,263 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source +set I18NSPHINXOPTS=%SPHINXOPTS% source +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 2> nul +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\SqlAlchemyDiff.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\SqlAlchemyDiff.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..fb87b1f --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# SqlAlchemy Diff documentation build configuration file, created by +# sphinx-quickstart on Fri Nov 13 15:01:12 2015. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os +import shlex + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'SqlAlchemy Diff' +copyright = '2015, Student.com' +author = 'Student.com' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.0.1' +# The full version, including alpha/beta/rc tags. +release = '0.0.1' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = [] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' +#html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# Now only 'ja' uses this config value +#html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +#html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'SqlAlchemyDiffdoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', + +# Latex figure (float) alignment +#'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'SqlAlchemyDiff.tex', 'SqlAlchemy Diff Documentation', + 'Student.com', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'sqlalchemydiff', 'SqlAlchemy Diff Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'SqlAlchemyDiff', 'SqlAlchemy Diff Documentation', + author, 'SqlAlchemyDiff', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False + + +# -- Options for Epub output ---------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project +epub_author = author +epub_publisher = author +epub_copyright = copyright + +# The basename for the epub file. It defaults to the project name. +#epub_basename = project + +# The HTML theme for the epub output. Since the default themes are not optimized +# for small screen space, using the same theme for HTML and epub output is +# usually not wise. This defaults to 'epub', a theme designed to save visual +# space. +#epub_theme = 'epub' + +# The language of the text. It defaults to the language option +# or 'en' if the language is not set. +#epub_language = '' + +# The scheme of the identifier. Typical schemes are ISBN or URL. +#epub_scheme = '' + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +#epub_identifier = '' + +# A unique identification for the text. +#epub_uid = '' + +# A tuple containing the cover image and cover page html template filenames. +#epub_cover = () + +# A sequence of (type, uri, title) tuples for the guide element of content.opf. +#epub_guide = () + +# HTML files that should be inserted before the pages created by sphinx. +# The format is a list of tuples containing the path and title. +#epub_pre_files = [] + +# HTML files shat should be inserted after the pages created by sphinx. +# The format is a list of tuples containing the path and title. +#epub_post_files = [] + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + +# The depth of the table of contents in toc.ncx. +#epub_tocdepth = 3 + +# Allow duplicate toc entries. +#epub_tocdup = True + +# Choose between 'default' and 'includehidden'. +#epub_tocscope = 'default' + +# Fix unsupported image types using the Pillow. +#epub_fix_images = False + +# Scale large images. +#epub_max_image_width = 0 + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#epub_show_urls = 'inline' + +# If false, no index is generated. +#epub_use_index = True + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/': None} diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..e69de29 From a3f376ec432f425e401c621fae0dfe0dc7c5977a Mon Sep 17 00:00:00 2001 From: Fabrizio Romano Date: Fri, 13 Nov 2015 15:05:04 +0000 Subject: [PATCH 06/12] PLAT-245 -- Add testing examples. --- docs/__init__.py | 0 docs/testing/__init__.py | 1 + docs/testing/models_left.py | 68 ++++++++++ docs/testing/models_right.py | 69 ++++++++++ docs/testing/test_example.py | 235 +++++++++++++++++++++++++++++++++++ 5 files changed, 373 insertions(+) create mode 100644 docs/__init__.py create mode 100644 docs/testing/__init__.py create mode 100644 docs/testing/models_left.py create mode 100644 docs/testing/models_right.py create mode 100644 docs/testing/test_example.py diff --git a/docs/__init__.py b/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/testing/__init__.py b/docs/testing/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/docs/testing/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/docs/testing/models_left.py b/docs/testing/models_left.py new file mode 100644 index 0000000..0565d9c --- /dev/null +++ b/docs/testing/models_left.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +from sqlalchemy import Column, ForeignKey, Integer, String, Unicode +from sqlalchemy.ext.declarative import declarative_base + + +Base = declarative_base() + + +class Employee(Base): + __tablename__ = "employees" + + id = Column(Integer, primary_key=True) + name = Column(Unicode(200), unique=True, index=True) + age = Column(Integer, nullable=False, default=21) + ssn = Column(Unicode(30), nullable=False) + number_of_pets = Column(Integer, default=1, nullable=False) + + company_id = Column( + Integer, + ForeignKey("companies.id", name="fk_employees_companies"), + nullable=False + ) + + role_id = Column( + Integer, + ForeignKey("roles.id", name="fk_employees_roles"), + nullable=False + ) + + +class Company(Base): + __tablename__ = "companies" + + id = Column(Integer, primary_key=True) + name = Column(Unicode(200), nullable=False, unique=True) + + +class Role(Base): + __tablename__ = "roles" + + id = Column(Integer, primary_key=True) + name = Column(Unicode(50), nullable=False) + + +class Skill(Base): + __tablename__ = "skills" + + slug = Column(String(50), primary_key=True) + description = Column(Unicode(100), nullable=True) + + employee = Column( + Integer, + ForeignKey("employees.id", name="fk_skills_employees"), + nullable=False + ) + + +class MobileNumber(Base): + __tablename__ = "mobile_numbers" + + id = Column(Integer, primary_key=True) + number = Column(String(40), nullable=False) + + owner = Column( + Integer, + ForeignKey("employees.id", name="fk_mobile_numbers_employees"), + nullable=False + ) diff --git a/docs/testing/models_right.py b/docs/testing/models_right.py new file mode 100644 index 0000000..8c42493 --- /dev/null +++ b/docs/testing/models_right.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +from sqlalchemy import Column, ForeignKey, Integer, String, Unicode +from sqlalchemy.ext.declarative import declarative_base + + +Base = declarative_base() + + +class Employee(Base): + __tablename__ = "employees" + + id = Column(Integer, primary_key=True) + name = Column(Unicode(200), unique=True, index=False) + age = Column(Integer, nullable=False, default=21) + ssn = Column(Unicode(30), nullable=False) + number_of_pets = Column(Integer, default=1, nullable=False) + + company_id = Column( + Integer, + ForeignKey("companies.id", name="fk_emp_comp"), + nullable=False + ) + + role_id = Column( + Integer, + ForeignKey("roles.id", name="fk_employees_roles"), + nullable=False + ) + + +class Company(Base): + __tablename__ = "companies" + + id = Column(Integer, primary_key=True) + name = Column(Unicode(200), nullable=True, unique=False) + + +class Role(Base): + __tablename__ = "roles" + + id = Column(Integer, primary_key=True) + name = Column(Unicode(60), nullable=False) + + +class Skill(Base): + __tablename__ = "skills" + + id = Column(Integer, primary_key=True) + slug = Column(String(50)) + description = Column(Unicode(100), nullable=True) + + employee = Column( + Integer, + ForeignKey("employees.id", name="fk_skills_employees"), + nullable=False + ) + + +class PhoneNumber(Base): + __tablename__ = "phone_numbers" + + id = Column(Integer, primary_key=True) + number = Column(String(40), nullable=False) + + owner = Column( + Integer, + ForeignKey("employees.id", name="fk_phone_numbers_employees"), + nullable=False + ) diff --git a/docs/testing/test_example.py b/docs/testing/test_example.py new file mode 100644 index 0000000..0009c11 --- /dev/null +++ b/docs/testing/test_example.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +import json + +import pytest + +from sqlalchemydiff.comparer import compare +from sqlalchemydiff.util import prepare_schema_from_models, walk_dict +from .models_left import Base as Base_left +from .models_right import Base as Base_right + +from test import assert_items_equal + + +@pytest.mark.usefixtures("new_db_left") +@pytest.mark.usefixtures("new_db_right") +def test_same_schema_is_the_same(uri_left, uri_right): + prepare_schema_from_models(uri_left, Base_right) + prepare_schema_from_models(uri_right, Base_right) + + result = compare(uri_left, uri_right) + + assert result.is_match + + +@pytest.mark.usefixtures("new_db_left") +@pytest.mark.usefixtures("new_db_right") +def test_schemas_are_different(uri_left, uri_right): + prepare_schema_from_models(uri_left, Base_left) + prepare_schema_from_models(uri_right, Base_right) + + result = compare(uri_left, uri_right) + + assert not result.is_match + + +@pytest.mark.usefixtures("new_db_left") +@pytest.mark.usefixtures("new_db_right") +def test_errors_dict_catches_all_differences(uri_left, uri_right): + prepare_schema_from_models(uri_left, Base_left) + prepare_schema_from_models(uri_right, Base_right) + + result = compare(uri_left, uri_right) + + expected_errors = { + 'tables': { + 'left_only': ['mobile_numbers'], + 'right_only': ['phone_numbers'], + }, + 'tables_data': { + 'companies': { + 'columns': { + 'diff': [ + { + 'key': 'name', + 'left': { + 'default': None, + 'name': 'name', + 'nullable': False, + 'type': 'VARCHAR(200)', + }, + 'right': { + 'default': None, + 'name': 'name', + 'nullable': True, + 'type': 'VARCHAR(200)', + } + } + ] + }, + 'indexes': { + 'left_only': [ + { + 'column_names': ['name'], + 'name': 'name', + 'type': 'UNIQUE', + 'unique': True, + } + ] + } + }, + 'employees': { + 'foreign_keys': { + 'left_only': [ + { + 'constrained_columns': ['company_id'], + 'name': 'fk_employees_companies', + 'options': {}, + 'referred_columns': ['id'], + 'referred_schema': None, + 'referred_table': 'companies' + } + ], + 'right_only': [ + { + 'constrained_columns': ['company_id'], + 'name': 'fk_emp_comp', + 'options': {}, + 'referred_columns': ['id'], + 'referred_schema': None, + 'referred_table': 'companies', + } + ] + }, + 'indexes': { + 'left_only': [ + { + 'column_names': ['name'], + 'name': 'ix_employees_name', + 'type': 'UNIQUE', + 'unique': True, + }, + { + 'column_names': ['company_id'], + 'name': 'fk_employees_companies', + 'unique': False, + } + ], + 'right_only': [ + { + 'column_names': ['company_id'], + 'name': 'fk_emp_comp', + 'unique': False, + }, + { + 'column_names': ['name'], + 'name': 'name', + 'type': 'UNIQUE', + 'unique': True, + } + ] + } + }, + 'roles': { + 'columns': { + 'diff': [ + { + 'key': 'name', + 'left': { + 'default': None, + 'name': 'name', + 'nullable': False, + 'type': 'VARCHAR(50)', + }, + 'right': { + 'default': None, + 'name': 'name', + 'nullable': False, + 'type': 'VARCHAR(60)', + } + } + ] + } + }, + 'skills': { + 'columns': { + 'diff': [ + { + 'key': 'slug', + 'left': { + 'default': None, + 'name': 'slug', + 'nullable': False, + 'type': 'VARCHAR(50)', + }, + 'right': { + 'default': None, + 'name': 'slug', + 'nullable': True, + 'type': 'VARCHAR(50)', + } + } + ], + 'right_only': [ + { + 'autoincrement': True, + 'default': None, + 'name': 'id', + 'nullable': False, + 'type': 'INTEGER(11)', + } + ] + }, + 'primary_keys': { + 'left_only': ['slug'], + 'right_only': ['id'], + } + } + }, + 'uris': { + 'left': uri_left, + 'right': uri_right + } + } + + assert not result.is_match + + compare_error_dicts(expected_errors, result.errors) + + +def compare_error_dicts(err1, err2): + """Smart comparer of error dicts. + + We cannot directly compare a nested dict structure that has lists + as values on some level. The order of the same list in the two dicts + could be different, which would lead to a failure in the comparison, + but it would be wrong as for us the order doesn't matter and we need + a comparison that only checks that the same items are in the lists. + In order to do this, we use the walk_dict function to perform a + smart comparison only on the lists. + + This function compares the ``tables`` and ``uris`` items, then it does + an order-insensitive comparison of all lists, and finally it compares + that the sorted JSON dump of both dicts is the same. + """ + assert err1['tables'] == err2['tables'] + assert err1['uris'] == err2['uris'] + + paths = [ + ['tables_data', 'companies', 'columns', 'diff'], + ['tables_data', 'companies', 'indexes', 'left_only'], + ['tables_data', 'employees', 'foreign_keys', 'left_only'], + ['tables_data', 'employees', 'foreign_keys', 'right_only'], + ['tables_data', 'employees', 'indexes', 'left_only'], + ['tables_data', 'employees', 'indexes', 'right_only'], + ['tables_data', 'roles', 'columns', 'diff'], + ['tables_data', 'skills', 'columns', 'diff'], + ['tables_data', 'skills', 'columns', 'right_only'], + ['tables_data', 'skills', 'primary_keys', 'left_only'], + ['tables_data', 'skills', 'primary_keys', 'right_only'], + ] + + for path in paths: + assert_items_equal(walk_dict(err1, path), walk_dict(err2, path)) + + assert sorted(json.dumps(err1)) == sorted(json.dumps(err2)) From ea1fcd577b948fa5c253c8ab23c8e11a016f533b Mon Sep 17 00:00:00 2001 From: Fabrizio Romano Date: Fri, 13 Nov 2015 17:34:26 +0000 Subject: [PATCH 07/12] PLAT-245 -- Amend and finish up docs. --- README.rst | 9 ++ docs/source/full_example.rst | 23 ++++ docs/source/index.rst | 117 ++++++++++++++++ docs/testing/test_example.py | 2 +- docs/testing/test_unittest.py | 246 ++++++++++++++++++++++++++++++++++ 5 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 docs/source/full_example.rst create mode 100644 docs/testing/test_unittest.py diff --git a/README.rst b/README.rst index e69de29..1ba6305 100644 --- a/README.rst +++ b/README.rst @@ -0,0 +1,9 @@ +SqlAlchemy Diff +=============== + +.. pull-quote:: + + Verify that your alembic migrations are valid and equivalent to your + models. + +Documentation here: (link to read the docs) diff --git a/docs/source/full_example.rst b/docs/source/full_example.rst new file mode 100644 index 0000000..49f899c --- /dev/null +++ b/docs/source/full_example.rst @@ -0,0 +1,23 @@ +.. _full_example: + +A full comparison PyTest Example +================================ + + +Let's assume we have the following models: + +``# models_left.py`` + +.. literalinclude:: ../testing/models_left.py + +And these: + +``# models_right.py`` + +.. literalinclude:: ../testing/models_right.py + + +This is how you could write a complete test suite for them: + + +.. literalinclude:: ../testing/test_example.py \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index e69de29..0377956 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -0,0 +1,117 @@ +Alembic Verify +============== + +.. pull-quote:: + + Verify that your alembic migrations are valid and equivalent to your + models. + + +PyTest Example +-------------- + +This is how you can use the library to verify that two schemas are +the same: + +.. literalinclude:: ../testing/test_example.py + :lines: 6,8,9,13-22 + + +You can also make sure that two schemas are different: + +.. literalinclude:: ../testing/test_example.py + :lines: 25-33 + + +If your test fails, you can dump the errors to a file by just adding +the following line of code: + +.. code-block:: Python + + result.dump_errors() + + +That will dump the errors dict to a JSON file that looks like this: + +.. code-block:: JSON + + { + 'tables': { + 'left_only': ['addresses'], + 'right_only': ['roles'] + }, + 'tables_data': { + 'employees': { + 'columns': { + 'left_only': [ + { + 'default': None, + 'name': 'favourite_meal', + 'nullable': False, + 'type': "ENUM('meat','vegan')" + } + ], + 'right_only': [ + { + 'autoincrement': False, + 'default': None, + 'name': 'role_id', + 'nullable': False, + 'type': 'INTEGER(11)' + }, + { + 'autoincrement': False, + 'default': None, + 'name': 'number_of_pets', + 'nullable': False, + 'type': 'INTEGER(11)' + }, + ] + }, + 'foreign_keys': { ... }, + 'primary_keys': { ... }, + 'indexes': { .. } + }, + 'phone_numbers': { ... } + }, + 'uris': { + 'left': "your left URI", + 'right': "your right URI", + } + } + + +Using unittest +-------------- + +If you prefer, you can use unittest: + +.. literalinclude:: ../testing/test_unittest.py + :lines: 2-47 + + +Features +-------- + +Currently the library can detect the following differences: + +- Differences in **Tables** +- Differences in **Primary Keys** for a common table +- Differences in **Foreign Keys** for a common table +- Differences in **Indexes** for a common table +- Differences in **Columns** for a common table + + +Installation +------------ + +.. code-block:: bash + + $ pip install alembic-verify + + +Full Example +------------ + +:ref:`Here ` you can find a full example on how to test +two databases which are different. diff --git a/docs/testing/test_example.py b/docs/testing/test_example.py index 0009c11..72e71f8 100644 --- a/docs/testing/test_example.py +++ b/docs/testing/test_example.py @@ -188,7 +188,7 @@ def test_errors_dict_catches_all_differences(uri_left, uri_right): }, 'uris': { 'left': uri_left, - 'right': uri_right + 'right': uri_right, } } diff --git a/docs/testing/test_unittest.py b/docs/testing/test_unittest.py new file mode 100644 index 0000000..1640bbd --- /dev/null +++ b/docs/testing/test_unittest.py @@ -0,0 +1,246 @@ +# -*- coding: utf-8 -*- +import json +import unittest + +from sqlalchemydiff.comparer import compare +from sqlalchemydiff.util import ( + destroy_database, + get_temporary_uri, + new_db, + prepare_schema_from_models, + walk_dict, +) +from .models_left import Base as Base_left +from .models_right import Base as Base_right + +from test import assert_items_equal + + +class TestSchemasAreDifferent(unittest.TestCase): + + def setUp(self): + uri = "mysql+mysqlconnector://root:@localhost/sqlalchemydiff" + self.uri_left = get_temporary_uri(uri) + self.uri_right = get_temporary_uri(uri) + + new_db(self.uri_left) + new_db(self.uri_right) + + def tearDown(self): + destroy_database(self.uri_left) + destroy_database(self.uri_right) + + def test_same_schema_is_the_same(self): + prepare_schema_from_models(self.uri_left, Base_right) + prepare_schema_from_models(self.uri_right, Base_right) + + result = compare(self.uri_left, self.uri_right) + + assert result.is_match + + def test_schemas_are_different(self): + prepare_schema_from_models(self.uri_left, Base_left) + prepare_schema_from_models(self.uri_right, Base_right) + + result = compare(self.uri_left, self.uri_right) + + assert not result.is_match + + def test_errors_dict_catches_all_differences(self): + prepare_schema_from_models(self.uri_left, Base_left) + prepare_schema_from_models(self.uri_right, Base_right) + + result = compare(self.uri_left, self.uri_right) + + expected_errors = { + 'tables': { + 'left_only': ['mobile_numbers'], + 'right_only': ['phone_numbers'], + }, + 'tables_data': { + 'companies': { + 'columns': { + 'diff': [ + { + 'key': 'name', + 'left': { + 'default': None, + 'name': 'name', + 'nullable': False, + 'type': 'VARCHAR(200)', + }, + 'right': { + 'default': None, + 'name': 'name', + 'nullable': True, + 'type': 'VARCHAR(200)', + } + } + ] + }, + 'indexes': { + 'left_only': [ + { + 'column_names': ['name'], + 'name': 'name', + 'type': 'UNIQUE', + 'unique': True, + } + ] + } + }, + 'employees': { + 'foreign_keys': { + 'left_only': [ + { + 'constrained_columns': ['company_id'], + 'name': 'fk_employees_companies', + 'options': {}, + 'referred_columns': ['id'], + 'referred_schema': None, + 'referred_table': 'companies' + } + ], + 'right_only': [ + { + 'constrained_columns': ['company_id'], + 'name': 'fk_emp_comp', + 'options': {}, + 'referred_columns': ['id'], + 'referred_schema': None, + 'referred_table': 'companies', + } + ] + }, + 'indexes': { + 'left_only': [ + { + 'column_names': ['name'], + 'name': 'ix_employees_name', + 'type': 'UNIQUE', + 'unique': True, + }, + { + 'column_names': ['company_id'], + 'name': 'fk_employees_companies', + 'unique': False, + } + ], + 'right_only': [ + { + 'column_names': ['company_id'], + 'name': 'fk_emp_comp', + 'unique': False, + }, + { + 'column_names': ['name'], + 'name': 'name', + 'type': 'UNIQUE', + 'unique': True, + } + ] + } + }, + 'roles': { + 'columns': { + 'diff': [ + { + 'key': 'name', + 'left': { + 'default': None, + 'name': 'name', + 'nullable': False, + 'type': 'VARCHAR(50)', + }, + 'right': { + 'default': None, + 'name': 'name', + 'nullable': False, + 'type': 'VARCHAR(60)', + } + } + ] + } + }, + 'skills': { + 'columns': { + 'diff': [ + { + 'key': 'slug', + 'left': { + 'default': None, + 'name': 'slug', + 'nullable': False, + 'type': 'VARCHAR(50)', + }, + 'right': { + 'default': None, + 'name': 'slug', + 'nullable': True, + 'type': 'VARCHAR(50)', + } + } + ], + 'right_only': [ + { + 'autoincrement': True, + 'default': None, + 'name': 'id', + 'nullable': False, + 'type': 'INTEGER(11)', + } + ] + }, + 'primary_keys': { + 'left_only': ['slug'], + 'right_only': ['id'], + } + } + }, + 'uris': { + 'left': self.uri_left, + 'right': self.uri_right, + } + } + + assert not result.is_match + + self.compare_error_dicts(expected_errors, result.errors) + + + def compare_error_dicts(self, err1, err2): + """Smart comparer of error dicts. + + We cannot directly compare a nested dict structure that has lists + as values on some level. The order of the same list in the two dicts + could be different, which would lead to a failure in the comparison, + but it would be wrong as for us the order doesn't matter and we need + a comparison that only checks that the same items are in the lists. + In order to do this, we use the walk_dict function to perform a + smart comparison only on the lists. + + This function compares the ``tables`` and ``uris`` items, then it does + an order-insensitive comparison of all lists, and finally it compares + that the sorted JSON dump of both dicts is the same. + """ + assert err1['tables'] == err2['tables'] + assert err1['uris'] == err2['uris'] + + paths = [ + ['tables_data', 'companies', 'columns', 'diff'], + ['tables_data', 'companies', 'indexes', 'left_only'], + ['tables_data', 'employees', 'foreign_keys', 'left_only'], + ['tables_data', 'employees', 'foreign_keys', 'right_only'], + ['tables_data', 'employees', 'indexes', 'left_only'], + ['tables_data', 'employees', 'indexes', 'right_only'], + ['tables_data', 'roles', 'columns', 'diff'], + ['tables_data', 'skills', 'columns', 'diff'], + ['tables_data', 'skills', 'columns', 'right_only'], + ['tables_data', 'skills', 'primary_keys', 'left_only'], + ['tables_data', 'skills', 'primary_keys', 'right_only'], + ] + + for path in paths: + assert_items_equal(walk_dict(err1, path), walk_dict(err2, path)) + + assert sorted(json.dumps(err1)) == sorted(json.dumps(err2)) From e748bdcb4091a12ff3050b0fb9b97e387e1b8904 Mon Sep 17 00:00:00 2001 From: Fabrizio Romano Date: Mon, 16 Nov 2015 10:14:47 +0000 Subject: [PATCH 08/12] PLAT-245 -- Finish up with the documentation. --- docs/source/index.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 0377956..25d66ba 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,10 +1,9 @@ -Alembic Verify -============== +SQLAlchemy Diff +=============== .. pull-quote:: - Verify that your alembic migrations are valid and equivalent to your - models. + Compare two database schemas using SQLAlchemy. PyTest Example @@ -15,12 +14,14 @@ the same: .. literalinclude:: ../testing/test_example.py :lines: 6,8,9,13-22 + :emphasize-lines: 11 You can also make sure that two schemas are different: .. literalinclude:: ../testing/test_example.py :lines: 25-33 + :emphasize-lines: 7 If your test fails, you can dump the errors to a file by just adding From 66652c27636f1f127ae53d7ba1ed2d0f87ac972a Mon Sep 17 00:00:00 2001 From: Fabrizio Romano Date: Mon, 16 Nov 2015 15:54:20 +0000 Subject: [PATCH 09/12] PLAT-245 -- Amend error in docs. --- README.rst | 5 ++--- docs/source/index.rst | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 1ba6305..15baaf7 100644 --- a/README.rst +++ b/README.rst @@ -1,9 +1,8 @@ -SqlAlchemy Diff +SQLAlchemy Diff =============== .. pull-quote:: - Verify that your alembic migrations are valid and equivalent to your - models. + Compare two database schemas using SQLAlchemy. Documentation here: (link to read the docs) diff --git a/docs/source/index.rst b/docs/source/index.rst index 25d66ba..667a660 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -108,7 +108,7 @@ Installation .. code-block:: bash - $ pip install alembic-verify + $ pip install sqlalchemy-diff Full Example From 29c963bbbdeedcf2ac76af9107df9fd7c53a7c86 Mon Sep 17 00:00:00 2001 From: Fabrizio Romano Date: Tue, 17 Nov 2015 12:12:23 +0000 Subject: [PATCH 10/12] PLAT-245 -- Amend .gitignore. --- .gitignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 8d285a1..4341497 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ *.py[cod] *.egg-info .coverage -wheelhouse -config -.env.* .cache .tox +docs/build From 6b86a882b34fdb0387f24c3fe4af4642573bd9fa Mon Sep 17 00:00:00 2001 From: Fabrizio Romano Date: Tue, 17 Nov 2015 14:07:18 +0000 Subject: [PATCH 11/12] PLAT-245 -- Amend library according to code review. --- README.rst | 3 +- docs/__init__.py | 0 docs/source/full_example.rst | 23 ---------- docs/source/index.rst | 44 ++++++------------- setup.py | 6 +-- sqlalchemydiff/util.py | 22 ---------- {docs/testing => test/endtoend}/__init__.py | 0 .../testing => test/endtoend}/models_left.py | 0 .../testing => test/endtoend}/models_right.py | 0 .../testing => test/endtoend}/test_example.py | 24 +++++++++- .../endtoend}/test_unittest.py | 42 +----------------- 11 files changed, 43 insertions(+), 121 deletions(-) delete mode 100644 docs/__init__.py delete mode 100644 docs/source/full_example.rst rename {docs/testing => test/endtoend}/__init__.py (100%) rename {docs/testing => test/endtoend}/models_left.py (100%) rename {docs/testing => test/endtoend}/models_right.py (100%) rename {docs/testing => test/endtoend}/test_example.py (94%) rename {docs/testing => test/endtoend}/test_unittest.py (80%) diff --git a/README.rst b/README.rst index 15baaf7..70e066a 100644 --- a/README.rst +++ b/README.rst @@ -3,6 +3,7 @@ SQLAlchemy Diff .. pull-quote:: - Compare two database schemas using SQLAlchemy. + Compare and generate a diff between two databases using SQLAlchemy's + inspection API. Documentation here: (link to read the docs) diff --git a/docs/__init__.py b/docs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/docs/source/full_example.rst b/docs/source/full_example.rst deleted file mode 100644 index 49f899c..0000000 --- a/docs/source/full_example.rst +++ /dev/null @@ -1,23 +0,0 @@ -.. _full_example: - -A full comparison PyTest Example -================================ - - -Let's assume we have the following models: - -``# models_left.py`` - -.. literalinclude:: ../testing/models_left.py - -And these: - -``# models_right.py`` - -.. literalinclude:: ../testing/models_right.py - - -This is how you could write a complete test suite for them: - - -.. literalinclude:: ../testing/test_example.py \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 667a660..75e2b1c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,36 +3,34 @@ SQLAlchemy Diff .. pull-quote:: - Compare two database schemas using SQLAlchemy. + Compare and generate a diff between two databases using SQLAlchemy's + inspection API PyTest Example -------------- -This is how you can use the library to verify that two schemas are -the same: +Comparing two schemas is easy. You can verify they are the same like +this: -.. literalinclude:: ../testing/test_example.py +.. literalinclude:: ../../test/endtoend/test_example.py :lines: 6,8,9,13-22 - :emphasize-lines: 11 -You can also make sure that two schemas are different: +You can also verify that they are different: -.. literalinclude:: ../testing/test_example.py +.. literalinclude:: ../../test/endtoend/test_example.py :lines: 25-33 - :emphasize-lines: 7 -If your test fails, you can dump the errors to a file by just adding -the following line of code: +You will get back a ``result`` object: ``result.is_match`` will be +``True`` when the two schemas are the same, and ``False`` when they are +different. -.. code-block:: Python +When two schemas don't match, you can call ``result.dump_errors()`` to +save all the differences between them to a JSON file that will look +like this: - result.dump_errors() - - -That will dump the errors dict to a JSON file that looks like this: .. code-block:: JSON @@ -82,15 +80,6 @@ That will dump the errors dict to a JSON file that looks like this: } -Using unittest --------------- - -If you prefer, you can use unittest: - -.. literalinclude:: ../testing/test_unittest.py - :lines: 2-47 - - Features -------- @@ -109,10 +98,3 @@ Installation .. code-block:: bash $ pip install sqlalchemy-diff - - -Full Example ------------- - -:ref:`Here ` you can find a full example on how to test -two databases which are different. diff --git a/setup.py b/setup.py index e69a400..cd91626 100644 --- a/setup.py +++ b/setup.py @@ -13,21 +13,21 @@ setup( name='sqlalchemy-diff', - version='0.0.1', + version='0.0.2', description='Compare two database schemas using sqlalchemy.', long_description=readme, author='student.com', - author_email='dark-side@student.com', # TODO - Add proper email here + author_email='wearehiring@student.com', url='https://github.com/Overseas-Student-Living/sqlalchemy-diff', packages=find_packages(exclude=['docs', 'test', 'test.*']), install_requires=[ "six==1.10.0", "mock==1.3.0", - "mysql-connector-python==2.0.4", "sqlalchemy-utils==0.31.2", ], extras_require={ 'dev': [ + "mysql-connector-python==2.0.4", "pytest==2.8.2", ], 'docs': [ diff --git a/sqlalchemydiff/util.py b/sqlalchemydiff/util.py index fb7204c..8297e45 100644 --- a/sqlalchemydiff/util.py +++ b/sqlalchemydiff/util.py @@ -103,25 +103,3 @@ def prepare_schema_from_models(uri, sqlalchemy_base): """Creates the database schema from the ``SQLAlchemy`` models. """ engine = create_engine(uri) sqlalchemy_base.metadata.create_all(engine) - - -def walk_dict(d, path): - """Walks a dict given a path of keys. - - For example, if we have a dict like this:: - - d = { - 'a': { - 'B': { - 1: ['hello', 'world'], - 2: ['hello', 'again'], - } - } - } - - Then ``walk_dict(d, ['a', 'B', 1])`` would return - ``['hello', 'world']``. - """ - if not path: - return d - return walk_dict(d[path[0]], path[1:]) diff --git a/docs/testing/__init__.py b/test/endtoend/__init__.py similarity index 100% rename from docs/testing/__init__.py rename to test/endtoend/__init__.py diff --git a/docs/testing/models_left.py b/test/endtoend/models_left.py similarity index 100% rename from docs/testing/models_left.py rename to test/endtoend/models_left.py diff --git a/docs/testing/models_right.py b/test/endtoend/models_right.py similarity index 100% rename from docs/testing/models_right.py rename to test/endtoend/models_right.py diff --git a/docs/testing/test_example.py b/test/endtoend/test_example.py similarity index 94% rename from docs/testing/test_example.py rename to test/endtoend/test_example.py index 72e71f8..ba8f1e3 100644 --- a/docs/testing/test_example.py +++ b/test/endtoend/test_example.py @@ -4,7 +4,7 @@ import pytest from sqlalchemydiff.comparer import compare -from sqlalchemydiff.util import prepare_schema_from_models, walk_dict +from sqlalchemydiff.util import prepare_schema_from_models from .models_left import Base as Base_left from .models_right import Base as Base_right @@ -233,3 +233,25 @@ def compare_error_dicts(err1, err2): assert_items_equal(walk_dict(err1, path), walk_dict(err2, path)) assert sorted(json.dumps(err1)) == sorted(json.dumps(err2)) + + +def walk_dict(d, path): + """Walks a dict given a path of keys. + + For example, if we have a dict like this:: + + d = { + 'a': { + 'B': { + 1: ['hello', 'world'], + 2: ['hello', 'again'], + } + } + } + + Then ``walk_dict(d, ['a', 'B', 1])`` would return + ``['hello', 'world']``. + """ + if not path: + return d + return walk_dict(d[path[0]], path[1:]) diff --git a/docs/testing/test_unittest.py b/test/endtoend/test_unittest.py similarity index 80% rename from docs/testing/test_unittest.py rename to test/endtoend/test_unittest.py index 1640bbd..934d847 100644 --- a/docs/testing/test_unittest.py +++ b/test/endtoend/test_unittest.py @@ -8,12 +8,12 @@ get_temporary_uri, new_db, prepare_schema_from_models, - walk_dict, ) from .models_left import Base as Base_left from .models_right import Base as Base_right from test import assert_items_equal +from test.endtoend.test_example import compare_error_dicts class TestSchemasAreDifferent(unittest.TestCase): @@ -205,42 +205,4 @@ def test_errors_dict_catches_all_differences(self): assert not result.is_match - self.compare_error_dicts(expected_errors, result.errors) - - - def compare_error_dicts(self, err1, err2): - """Smart comparer of error dicts. - - We cannot directly compare a nested dict structure that has lists - as values on some level. The order of the same list in the two dicts - could be different, which would lead to a failure in the comparison, - but it would be wrong as for us the order doesn't matter and we need - a comparison that only checks that the same items are in the lists. - In order to do this, we use the walk_dict function to perform a - smart comparison only on the lists. - - This function compares the ``tables`` and ``uris`` items, then it does - an order-insensitive comparison of all lists, and finally it compares - that the sorted JSON dump of both dicts is the same. - """ - assert err1['tables'] == err2['tables'] - assert err1['uris'] == err2['uris'] - - paths = [ - ['tables_data', 'companies', 'columns', 'diff'], - ['tables_data', 'companies', 'indexes', 'left_only'], - ['tables_data', 'employees', 'foreign_keys', 'left_only'], - ['tables_data', 'employees', 'foreign_keys', 'right_only'], - ['tables_data', 'employees', 'indexes', 'left_only'], - ['tables_data', 'employees', 'indexes', 'right_only'], - ['tables_data', 'roles', 'columns', 'diff'], - ['tables_data', 'skills', 'columns', 'diff'], - ['tables_data', 'skills', 'columns', 'right_only'], - ['tables_data', 'skills', 'primary_keys', 'left_only'], - ['tables_data', 'skills', 'primary_keys', 'right_only'], - ] - - for path in paths: - assert_items_equal(walk_dict(err1, path), walk_dict(err2, path)) - - assert sorted(json.dumps(err1)) == sorted(json.dumps(err2)) + compare_error_dicts(expected_errors, result.errors) From 5015c71f9cd05568d7de0a64dd34f5428521531c Mon Sep 17 00:00:00 2001 From: Fabrizio Romano Date: Thu, 19 Nov 2015 10:26:21 +0000 Subject: [PATCH 12/12] PLAT-245 -- Amend library as per PR. --- docs/source/index.rst | 127 ++++++++++---------- setup.py | 7 +- sqlalchemydiff/pyfixtures.py | 37 ------ test/endtoend/conftest.py | 7 ++ test/endtoend/test_example.py | 31 ++++- test/endtoend/test_unittest.py | 208 --------------------------------- 6 files changed, 102 insertions(+), 315 deletions(-) delete mode 100644 sqlalchemydiff/pyfixtures.py create mode 100644 test/endtoend/conftest.py delete mode 100644 test/endtoend/test_unittest.py diff --git a/docs/source/index.rst b/docs/source/index.rst index 75e2b1c..6c281d1 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -13,71 +13,72 @@ PyTest Example Comparing two schemas is easy. You can verify they are the same like this: -.. literalinclude:: ../../test/endtoend/test_example.py - :lines: 6,8,9,13-22 - - -You can also verify that they are different: - -.. literalinclude:: ../../test/endtoend/test_example.py - :lines: 25-33 - - -You will get back a ``result`` object: ``result.is_match`` will be -``True`` when the two schemas are the same, and ``False`` when they are -different. - -When two schemas don't match, you can call ``result.dump_errors()`` to -save all the differences between them to a JSON file that will look -like this: - - -.. code-block:: JSON - - { - 'tables': { - 'left_only': ['addresses'], - 'right_only': ['roles'] - }, - 'tables_data': { - 'employees': { - 'columns': { - 'left_only': [ - { - 'default': None, - 'name': 'favourite_meal', - 'nullable': False, - 'type': "ENUM('meat','vegan')" - } - ], - 'right_only': [ - { - 'autoincrement': False, - 'default': None, - 'name': 'role_id', - 'nullable': False, - 'type': 'INTEGER(11)' - }, - { - 'autoincrement': False, - 'default': None, - 'name': 'number_of_pets', - 'nullable': False, - 'type': 'INTEGER(11)' +.. code-block:: Python + + >>> result = compare(uri_left, uri_right) + >>> result.is_match + True + + +When they are different, ``result.is_match`` will be ``False``. + +When two schemas don't match, you can inspect the differences between +them by looking at the ``errors`` dict on the ``result``: + +.. code-block:: Python + + >>> result = compare(uri_left, uri_right) + >>> result.is_match + False + >>> result.errors + { + 'tables': { + 'left_only': ['addresses'], + 'right_only': ['roles'] + }, + 'tables_data': { + 'employees': { + 'columns': { + 'left_only': [ + { + 'default': None, + 'name': 'favourite_meal', + 'nullable': False, + 'type': "ENUM('meat','vegan')" + } + ], + 'right_only': [ + { + 'autoincrement': False, + 'default': None, + 'name': 'role_id', + 'nullable': False, + 'type': 'INTEGER(11)' + }, + { + 'autoincrement': False, + 'default': None, + 'name': 'number_of_pets', + 'nullable': False, + 'type': 'INTEGER(11)' + }, + ] }, - ] + 'foreign_keys': { ... }, + 'primary_keys': { ... }, + 'indexes': { .. } + }, + 'phone_numbers': { ... } }, - 'foreign_keys': { ... }, - 'primary_keys': { ... }, - 'indexes': { .. } - }, - 'phone_numbers': { ... } - }, - 'uris': { - 'left': "your left URI", - 'right': "your right URI", - } - } + 'uris': { + 'left': "your left URI", + 'right': "your right URI", + } + } + + +If you wish to persist that dict to a JSON file, you can quickly do so +by calling ``result.dump_errors()``. Features diff --git a/setup.py b/setup.py index cd91626..23399f8 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setup( name='sqlalchemy-diff', - version='0.0.2', + version='0.0.3', description='Compare two database schemas using sqlalchemy.', long_description=readme, author='student.com', @@ -34,11 +34,6 @@ "Sphinx==1.3.1", ], }, - entry_points={ - 'pytest11': [ - 'sqlalchemy_diff=sqlalchemydiff.pyfixtures' - ] - }, zip_safe=True, license='Apache License, Version 2.0', classifiers=[ diff --git a/sqlalchemydiff/pyfixtures.py b/sqlalchemydiff/pyfixtures.py deleted file mode 100644 index 48ba6f5..0000000 --- a/sqlalchemydiff/pyfixtures.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -import pytest - -from .util import ( - get_temporary_uri, - new_db, - destroy_database, -) - - -@pytest.fixture(scope="module") -def db_uri(): - return "mysql+mysqlconnector://root:@localhost/sqlalchemydiff" - - -@pytest.fixture(scope="module") -def uri_left(db_uri): - return get_temporary_uri(db_uri) - - -@pytest.fixture(scope="module") -def uri_right(db_uri): - return get_temporary_uri(db_uri) - - -@pytest.yield_fixture -def new_db_left(uri_left): - new_db(uri_left) - yield - destroy_database(uri_left) - - -@pytest.yield_fixture -def new_db_right(uri_right): - new_db(uri_right) - yield - destroy_database(uri_right) \ No newline at end of file diff --git a/test/endtoend/conftest.py b/test/endtoend/conftest.py new file mode 100644 index 0000000..ace9860 --- /dev/null +++ b/test/endtoend/conftest.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +import pytest + + +@pytest.fixture(scope="module") +def db_uri(): + return "mysql+mysqlconnector://root:@localhost/sqlalchemydiff" diff --git a/test/endtoend/test_example.py b/test/endtoend/test_example.py index ba8f1e3..2aa0881 100644 --- a/test/endtoend/test_example.py +++ b/test/endtoend/test_example.py @@ -4,13 +4,42 @@ import pytest from sqlalchemydiff.comparer import compare -from sqlalchemydiff.util import prepare_schema_from_models +from sqlalchemydiff.util import ( + destroy_database, + get_temporary_uri, + new_db, + prepare_schema_from_models, +) from .models_left import Base as Base_left from .models_right import Base as Base_right from test import assert_items_equal +@pytest.fixture(scope="module") +def uri_left(db_uri): + return get_temporary_uri(db_uri) + + +@pytest.fixture(scope="module") +def uri_right(db_uri): + return get_temporary_uri(db_uri) + + +@pytest.yield_fixture +def new_db_left(uri_left): + new_db(uri_left) + yield + destroy_database(uri_left) + + +@pytest.yield_fixture +def new_db_right(uri_right): + new_db(uri_right) + yield + destroy_database(uri_right) + + @pytest.mark.usefixtures("new_db_left") @pytest.mark.usefixtures("new_db_right") def test_same_schema_is_the_same(uri_left, uri_right): diff --git a/test/endtoend/test_unittest.py b/test/endtoend/test_unittest.py deleted file mode 100644 index 934d847..0000000 --- a/test/endtoend/test_unittest.py +++ /dev/null @@ -1,208 +0,0 @@ -# -*- coding: utf-8 -*- -import json -import unittest - -from sqlalchemydiff.comparer import compare -from sqlalchemydiff.util import ( - destroy_database, - get_temporary_uri, - new_db, - prepare_schema_from_models, -) -from .models_left import Base as Base_left -from .models_right import Base as Base_right - -from test import assert_items_equal -from test.endtoend.test_example import compare_error_dicts - - -class TestSchemasAreDifferent(unittest.TestCase): - - def setUp(self): - uri = "mysql+mysqlconnector://root:@localhost/sqlalchemydiff" - self.uri_left = get_temporary_uri(uri) - self.uri_right = get_temporary_uri(uri) - - new_db(self.uri_left) - new_db(self.uri_right) - - def tearDown(self): - destroy_database(self.uri_left) - destroy_database(self.uri_right) - - def test_same_schema_is_the_same(self): - prepare_schema_from_models(self.uri_left, Base_right) - prepare_schema_from_models(self.uri_right, Base_right) - - result = compare(self.uri_left, self.uri_right) - - assert result.is_match - - def test_schemas_are_different(self): - prepare_schema_from_models(self.uri_left, Base_left) - prepare_schema_from_models(self.uri_right, Base_right) - - result = compare(self.uri_left, self.uri_right) - - assert not result.is_match - - def test_errors_dict_catches_all_differences(self): - prepare_schema_from_models(self.uri_left, Base_left) - prepare_schema_from_models(self.uri_right, Base_right) - - result = compare(self.uri_left, self.uri_right) - - expected_errors = { - 'tables': { - 'left_only': ['mobile_numbers'], - 'right_only': ['phone_numbers'], - }, - 'tables_data': { - 'companies': { - 'columns': { - 'diff': [ - { - 'key': 'name', - 'left': { - 'default': None, - 'name': 'name', - 'nullable': False, - 'type': 'VARCHAR(200)', - }, - 'right': { - 'default': None, - 'name': 'name', - 'nullable': True, - 'type': 'VARCHAR(200)', - } - } - ] - }, - 'indexes': { - 'left_only': [ - { - 'column_names': ['name'], - 'name': 'name', - 'type': 'UNIQUE', - 'unique': True, - } - ] - } - }, - 'employees': { - 'foreign_keys': { - 'left_only': [ - { - 'constrained_columns': ['company_id'], - 'name': 'fk_employees_companies', - 'options': {}, - 'referred_columns': ['id'], - 'referred_schema': None, - 'referred_table': 'companies' - } - ], - 'right_only': [ - { - 'constrained_columns': ['company_id'], - 'name': 'fk_emp_comp', - 'options': {}, - 'referred_columns': ['id'], - 'referred_schema': None, - 'referred_table': 'companies', - } - ] - }, - 'indexes': { - 'left_only': [ - { - 'column_names': ['name'], - 'name': 'ix_employees_name', - 'type': 'UNIQUE', - 'unique': True, - }, - { - 'column_names': ['company_id'], - 'name': 'fk_employees_companies', - 'unique': False, - } - ], - 'right_only': [ - { - 'column_names': ['company_id'], - 'name': 'fk_emp_comp', - 'unique': False, - }, - { - 'column_names': ['name'], - 'name': 'name', - 'type': 'UNIQUE', - 'unique': True, - } - ] - } - }, - 'roles': { - 'columns': { - 'diff': [ - { - 'key': 'name', - 'left': { - 'default': None, - 'name': 'name', - 'nullable': False, - 'type': 'VARCHAR(50)', - }, - 'right': { - 'default': None, - 'name': 'name', - 'nullable': False, - 'type': 'VARCHAR(60)', - } - } - ] - } - }, - 'skills': { - 'columns': { - 'diff': [ - { - 'key': 'slug', - 'left': { - 'default': None, - 'name': 'slug', - 'nullable': False, - 'type': 'VARCHAR(50)', - }, - 'right': { - 'default': None, - 'name': 'slug', - 'nullable': True, - 'type': 'VARCHAR(50)', - } - } - ], - 'right_only': [ - { - 'autoincrement': True, - 'default': None, - 'name': 'id', - 'nullable': False, - 'type': 'INTEGER(11)', - } - ] - }, - 'primary_keys': { - 'left_only': ['slug'], - 'right_only': ['id'], - } - } - }, - 'uris': { - 'left': self.uri_left, - 'right': self.uri_right, - } - } - - assert not result.is_match - - compare_error_dicts(expected_errors, result.errors)