diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4341497 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.py[cod] +*.egg-info +.coverage +.cache +.tox +docs/build diff --git a/README.rst b/README.rst index e69de29..70e066a 100644 --- a/README.rst +++ b/README.rst @@ -0,0 +1,9 @@ +SQLAlchemy Diff +=============== + +.. pull-quote:: + + Compare and generate a diff between two databases using SQLAlchemy's + inspection API. + +Documentation here: (link to read the docs) 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..6c281d1 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,101 @@ +SQLAlchemy Diff +=============== + +.. pull-quote:: + + Compare and generate a diff between two databases using SQLAlchemy's + inspection API + + +PyTest Example +-------------- + +Comparing two schemas is easy. You can verify they are the same like +this: + +.. 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': { ... } + }, + '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 +-------- + +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 sqlalchemy-diff diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..23399f8 --- /dev/null +++ b/setup.py @@ -0,0 +1,51 @@ +#!/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.3', + description='Compare two database schemas using sqlalchemy.', + long_description=readme, + author='student.com', + 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", + "sqlalchemy-utils==0.31.2", + ], + extras_require={ + 'dev': [ + "mysql-connector-python==2.0.4", + "pytest==2.8.2", + ], + 'docs': [ + "Sphinx==1.3.1", + ], + }, + 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/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/util.py b/sqlalchemydiff/util.py new file mode 100644 index 0000000..8297e45 --- /dev/null +++ b/sqlalchemydiff/util.py @@ -0,0 +1,105 @@ +# -*- 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) 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/endtoend/__init__.py b/test/endtoend/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/test/endtoend/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- 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/models_left.py b/test/endtoend/models_left.py new file mode 100644 index 0000000..0565d9c --- /dev/null +++ b/test/endtoend/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/test/endtoend/models_right.py b/test/endtoend/models_right.py new file mode 100644 index 0000000..8c42493 --- /dev/null +++ b/test/endtoend/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/test/endtoend/test_example.py b/test/endtoend/test_example.py new file mode 100644 index 0000000..2aa0881 --- /dev/null +++ b/test/endtoend/test_example.py @@ -0,0 +1,286 @@ +# -*- coding: utf-8 -*- +import json + +import pytest + +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 + + +@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): + 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)) + + +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/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 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