Skip to content

Commit

Permalink
CLI. Closes #6 (#51)
Browse files Browse the repository at this point in the history
* Initial CLI draft

* Fix lint

* Fix lint

* Initial xdump command for PG

* Actually dump data

* Test for multiple full tables

* PyPy fix

* partial tables specification

* Update coverage config

* Add compression option

* Extra options

* SQLite support

* Refactoring

* Use callback for parameter validation

* Fix for Python 2

* Decompose decorators processing

* Decompose CLI

* Add tests for utils

* Better boolean handling

* Fix imports

* Docstrings

* Skip old SQLite

* PyPy fix

* Remove a test

* Refactor cli fixture

* Fix arg

* Add xload command

* Refactoring

* Add an option for DB re-creation / truncation

* Update README

* Fix SQLite test
  • Loading branch information
Stranger6667 committed Aug 10, 2018
1 parent 827bca3 commit aa8dc61
Show file tree
Hide file tree
Showing 18 changed files with 456 additions and 9 deletions.
4 changes: 3 additions & 1 deletion .coveragerc
Expand Up @@ -4,4 +4,6 @@ branch = true
[report]
show_missing = true
precision = 2
exclude_lines = raise NotImplementedError
exclude_lines =
raise NotImplementedError
pass
53 changes: 53 additions & 0 deletions README.rst
Expand Up @@ -73,6 +73,59 @@ For example, if the ``employees`` table has foreign keys ``group_id`` (to ``grou
(to ``employees`` table) the resulting dump will have all objects related to selected employees
(as well as for objects related to related objects, recursively).

Command Line Interface
======================

``xload`` provides an ability to create a dump.

Signature:

.. code-block:: bash
xdump [postgres|sqlite] [OPTIONS]
Common options::

-o, --output TEXT output file name [required]
-f, --full TEXT table name to be fully dumped. Could be used
multiple times
-p, --partial TEXT partial tables specification in a form
"table_name:select SQL". Could be used
multiple times
-c, --compression [deflated|stored|bzip2|lzma]
dump compression level
--schema / --no-schema include / exclude the schema from the dump
--data / --no-data include / exclude the data from the dump
-D, --dbname TEXT database to work with [required]
-v, --verbosity verbosity level

PostgreSQL-specific options::

-U, --user TEXT connect as specified database user
[required]
-W, --password TEXT password for the DB connection
-H, --host TEXT database server host or socket directory
-P, --port TEXT database server port number

``xload`` loads a dump into a database.

Signature:


.. code-block:: bash
xload [postgres|sqlite] [OPTIONS]
Common options::

-i, --input TEXT input file name [required]
-m, --cleanup-method [recreate|truncate]
method of DB cleaning up
-D, --dbname TEXT database to work with [required]
-v, --verbosity verbosity level

PostgreSQL-specific options are the same as for ``xdump``.

RDBMS support
=============

Expand Down
2 changes: 2 additions & 0 deletions docs/changelog.rst
Expand Up @@ -11,6 +11,7 @@ Added

- Possibility to truncate data in the database instead of the DB re-creation.
``truncate`` method in DB backends and ``--truncate`` option for Django integration. `#48`_
- Command Line Interface. `#6`_

Changed
~~~~~~~
Expand Down Expand Up @@ -117,3 +118,4 @@ Fixed
.. _#13: https://github.com/Stranger6667/xdump/issues/13
.. _#8: https://github.com/Stranger6667/xdump/issues/8
.. _#7: https://github.com/Stranger6667/xdump/issues/7
.. _#6: https://github.com/Stranger6667/xdump/issues/6
2 changes: 1 addition & 1 deletion setup.cfg
Expand Up @@ -5,7 +5,7 @@ universal = 1
line_length = 119
combine_as_imports = true
known_first_party = xdump
known_third_party = attr,psycopg2,pytest,django
known_third_party = attr,psycopg2,pytest,django,click
include_trailing_comma = true
multi_line_output = 3
not_skip = __init__.py
Expand Down
13 changes: 11 additions & 2 deletions setup.py
Expand Up @@ -9,7 +9,11 @@
with open('README.rst') as file:
long_description = file.read()

install_requires = ['attrs', 'psycopg2']
install_requires = [
'attrs<19',
'psycopg2<2.8',
'click<7',
]
if sys.version_info[0] == 2:
install_requires.append('repoze.lru==0.7')

Expand Down Expand Up @@ -44,5 +48,10 @@
install_requires=install_requires,
extras_require={
'django': ['django>=1.11'],
}
},
entry_points='''
[console_scripts]
xdump=xdump.cli.dump:dump
xload=xdump.cli.load:load
'''
)
Empty file added tests/cli/__init__.py
Empty file.
55 changes: 55 additions & 0 deletions tests/cli/conftest.py
@@ -0,0 +1,55 @@
import sqlite3

import pytest

from xdump.cli import dump, load

from ..conftest import DATABASE, IS_POSTGRES, IS_SQLITE


@pytest.fixture
def cli(request, archive_filename, isolated_cli_runner):
if IS_SQLITE and sqlite3.sqlite_version_info < (3, 8, 3):
pytest.skip('Unsupported SQLite version')

commands = {
'sqlite': {
'dump': dump.sqlite,
'load': load.sqlite,
},
'postgres': {
'dump': dump.postgres,
'load': load.postgres,
}
}[DATABASE]

class CLI(object):

def call(self, command, *args):
default_args = ()
if IS_SQLITE:
dbname = request.getfixturevalue('dbname')
default_args = (
'-D', dbname,
)
elif IS_POSTGRES:
dsn_parameters = request.getfixturevalue('dsn_parameters')
default_args = (
'-U', dsn_parameters['user'],
'-H', dsn_parameters['host'],
'-P', dsn_parameters['port'],
'-D', dsn_parameters['dbname'],
)
return isolated_cli_runner.invoke(
command,
default_args + args,
catch_exceptions=False
)

def dump(self, *args):
return self.call(commands['dump'], '-o', archive_filename, *args)

def load(self, *args):
return self.call(commands['load'], '-i', archive_filename, *args)

return CLI()
14 changes: 14 additions & 0 deletions tests/cli/test_base.py
@@ -0,0 +1,14 @@
import pytest

from xdump import __version__
from xdump.cli import dump, load


@pytest.mark.parametrize('command, name', (
(dump.dump, 'xdump'), (load.load, 'xload')
))
def test_xdump_run(isolated_cli_runner, command, name):
"""Smoke test for a click group."""
result = isolated_cli_runner.invoke(command, ('--version', ))
assert not result.exception
assert result.output == '{0}, version {1}\n'.format(name, __version__)
66 changes: 66 additions & 0 deletions tests/cli/test_dump.py
@@ -0,0 +1,66 @@
import zipfile

import pytest


@pytest.mark.usefixtures('schema', 'data')
def test_single_full_table(cli, archive_filename, db_helper):
result = cli.dump('-f', 'groups')
assert not result.exception
assert result.output == 'Dumping ...\nOutput file: {0}\nDone!\n'.format(archive_filename)
archive = zipfile.ZipFile(archive_filename)
db_helper.assert_groups(archive)


@pytest.mark.usefixtures('schema', 'data')
def test_multiple_full_tables(cli, archive_filename, db_helper):
result = cli.dump('-f', 'groups', '-f' 'tickets')
assert not result.exception
archive = zipfile.ZipFile(archive_filename)
db_helper.assert_groups(archive)
db_helper.assert_content(
archive,
'tickets',
{
b'id,author_id,subject,message',
b'1,1,Sub 1,Message 1',
b'2,2,Sub 2,Message 2',
b'3,2,Sub 3,Message 3',
b'4,2,Sub 4,Message 4',
b'5,3,Sub 5,Message 5',
}
)


@pytest.mark.usefixtures('schema', 'data')
def test_partial_tables(cli, archive_filename, db_helper):
result = cli.dump('-p', 'employees:SELECT * FROM employees WHERE id = 1')
assert not result.exception
archive = zipfile.ZipFile(archive_filename)
db_helper.assert_content(archive, 'groups', {b'id,name', b'1,Admin'})
db_helper.assert_content(
archive,
'employees',
{
b'id,first_name,last_name,manager_id,referrer_id,group_id',
b'1,John,Doe,,,1',
}
)


@pytest.mark.usefixtures('schema', 'data')
def test_partial_tables_invalid(cli):
result = cli.dump('-p', 'shit')
assert result.exception
assert result.output.endswith(
'Invalid value for "-p" / "--partial": partial table specification should be in '
'the following format: "table:select SQL"\n'
)


@pytest.mark.usefixtures('schema', 'data')
def test_no_schema(cli, archive_filename):
result = cli.dump('-f', 'groups', '--no-schema')
assert not result.exception
archive = zipfile.ZipFile(archive_filename)
assert archive.namelist() == ['dump/data/groups.csv']
39 changes: 39 additions & 0 deletions tests/cli/test_load.py
@@ -0,0 +1,39 @@
import sqlite3

import pytest

from ..conftest import EMPLOYEES_SQL, IS_POSTGRES


@pytest.mark.usefixtures('schema', 'data')
def test_load(backend, cli, archive_filename, db_helper):
backend.dump(archive_filename, ['groups'], {'employees': EMPLOYEES_SQL})
backend.recreate_database()
if IS_POSTGRES:
backend.run('COMMIT')
assert db_helper.get_tables_count() == 0
result = cli.load('-i', archive_filename)
assert not result.exception
assert db_helper.get_tables_count() == 3


@pytest.mark.parametrize('cleanup_method, dump_kwargs', (
('truncate', {'dump_schema': False}),
('recreate', {}),
))
@pytest.mark.usefixtures('schema', 'data')
def test_cleanup_methods(cli, archive_filename, backend, cleanup_method, dump_kwargs):
backend.dump(archive_filename, ['groups'], {'employees': EMPLOYEES_SQL}, **dump_kwargs)
try:
backend.run('COMMIT')
except sqlite3.OperationalError:
pass
result = cli.load('-i', archive_filename, '-m', cleanup_method)
assert not result.exception
assert backend.run('SELECT name FROM groups') == [{'name': 'Admin'}, {'name': 'User'}]
assert backend.run('SELECT id, first_name, last_name FROM employees') == [
{'id': 5, 'last_name': 'Snow', 'first_name': 'John'},
{'id': 4, 'first_name': 'John', 'last_name': 'Brown'},
{'id': 3, 'first_name': 'John', 'last_name': 'Smith'},
{'id': 1, 'first_name': 'John', 'last_name': 'Doe'},
]
25 changes: 25 additions & 0 deletions tests/cli/test_utils.py
@@ -0,0 +1,25 @@
from xdump.base import BaseBackend
from xdump.cli.utils import apply_decorators, import_backend


def test_import_backend():
backend_class = import_backend('xdump.sqlite.SQLiteBackend')
assert issubclass(backend_class, BaseBackend)


def test_apply_decorators():

def dec1(func):
func.foo = 1
return func

def dec2(func):
func.bar = 2
return func

@apply_decorators([dec1, dec2])
def func():
pass

assert func.foo == 1
assert func.bar == 2
14 changes: 9 additions & 5 deletions tests/conftest.py
Expand Up @@ -58,6 +58,14 @@ def dbname(tmpdir):
return str(tmpdir.join('test.db'))


@pytest.fixture
def dsn_parameters(request):
postgresql = request.getfixturevalue('postgresql')
if platform.python_implementation() == 'PyPy':
return dict(item.split('=') for item in postgresql.dsn.split())
return postgresql.get_dsn_parameters()


@attr.s(cmp=False)
class BackendWrapper(object):
"""
Expand Down Expand Up @@ -216,11 +224,7 @@ def backend(request):
if IS_POSTGRES:
from xdump.postgresql import PostgreSQLBackend

postgresql = request.getfixturevalue('postgresql')
if platform.python_implementation() == 'PyPy':
parameters = dict(item.split('=') for item in postgresql.dsn.split())
else:
parameters = postgresql.get_dsn_parameters()
parameters = request.getfixturevalue('dsn_parameters')
return PostgreSQLBackend(
dbname=parameters['dbname'],
user=parameters['user'],
Expand Down
1 change: 1 addition & 0 deletions tox.ini
Expand Up @@ -6,6 +6,7 @@ deps =
pytest
postgres: pytest-postgresql
pytest-django
pytest-click
django
coverage
py27,pypy: mock
Expand Down
Empty file added xdump/cli/__init__.py
Empty file.
15 changes: 15 additions & 0 deletions xdump/cli/base.py
@@ -0,0 +1,15 @@
import click


COMMON_DECORATORS = [
click.option('-D', '--dbname', required=True, help='database to work with'),
click.option('-v', '--verbosity', help='verbosity level', default=0, count=True, type=click.IntRange(0, 2)),
]


PG_DECORATORS = [
click.option('-U', '--user', required=True, help='connect as specified database user'),
click.option('-W', '--password', help='password for the DB connection'),
click.option('-H', '--host', default='127.0.0.1', help='database server host or socket directory'),
click.option('-P', '--port', default='5432', help='database server port number'),
]

0 comments on commit aa8dc61

Please sign in to comment.