diff --git a/.gitignore b/.gitignore index e43e3f0..81b9048 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ data *.py[cod] -development.ini # C extensions *.so diff --git a/alembic.ini b/alembic.ini index ff73b03..fe56d6a 100644 --- a/alembic.ini +++ b/alembic.ini @@ -11,7 +11,7 @@ script_location = migrations # the 'revision' command, regardless of autogenerate # revision_environment = false -sqlalchemy.url = postgresql://robert@/ids +sqlalchemy.url = postgresql://postgres@/ids [production] script_location = migrations diff --git a/sqlite.ini b/development.ini similarity index 55% rename from sqlite.ini rename to development.ini index b0a4188..7f83239 100644 --- a/sqlite.ini +++ b/development.ini @@ -3,30 +3,20 @@ use = egg:ids pyramid.reload_templates = true pyramid.debug_authorization = false -pyramid.debug_notfound = true +pyramid.debug_notfound = false pyramid.debug_routematch = false pyramid.default_locale_name = en pyramid.includes = pyramid_tm - -sqlalchemy.url = sqlite:///%(here)s/ids.sqlite - -### -# wsgi server configuration -### +sqlalchemy.url = postgresql://postgres@/ids [server:main] use = egg:waitress#main -host = 127.0.0.1 +host = 0.0.0.0 port = 6543 -### -# logging configuration -# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html -### - [loggers] -keys = root, ids, sqlalchemy +keys = root, ids [handlers] keys = console @@ -43,14 +33,6 @@ level = DEBUG handlers = qualname = ids -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine -# "level = INFO" logs SQL queries. -# "level = DEBUG" logs SQL queries and results. -# "level = WARN" logs neither. (Recommended for production systems.) - [handler_console] class = StreamHandler args = (sys.stderr,) diff --git a/fabfile.py b/fabfile.py deleted file mode 100644 index 649d647..0000000 --- a/fabfile.py +++ /dev/null @@ -1,2 +0,0 @@ -from clldfabric import tasks -tasks.init('ids') diff --git a/ids/adapters.py b/ids/adapters.py index 1e3e72e..d728426 100644 --- a/ids/adapters.py +++ b/ids/adapters.py @@ -1,10 +1,9 @@ from __future__ import unicode_literals from clld.db.meta import DBSession -from clld.db.models.common import Language, Identifier +from clld.db.models.common import Language from clld.web.adapters.geojson import GeoJsonParameter, GeoJsonLanguages -from clld.web.adapters.cldf import CldfDataset -from clld.interfaces import IParameter, IContribution, IIndex, ICldfDataset +from clld.interfaces import IParameter, IContribution, IIndex class GeoJsonMeaning(GeoJsonParameter): @@ -21,48 +20,6 @@ def feature_iterator(self, ctx, req): return DBSession.query(Language) -class CldfDictionary(CldfDataset): - def columns(self, req): - return [ - 'ID', - { - 'name': 'Language_ID', - 'valueUrl': Identifier(type='glottolog', name='{Language_ID}').url()}, - 'Language_name', - { - 'name': 'Parameter_ID', - 'valueUrl': 'http://concepticon.clld.org/parameters/{Parameter_ID}'}, - 'Value', - 'Transcription', - 'Concept', - 'Source', - 'Comment', - 'AlternativeValue', - 'AlternativeTranscription', - ] - - def refs_and_sources(self, req, value): - if not hasattr(self, '_refs_and_sources'): - self._refs_and_sources = CldfDataset.refs_and_sources(self, req, self.obj) - return self._refs_and_sources - - def row(self, req, value, refs): - return [ - value.id, - self.obj.language.glottocode, - self.obj.language.name, - value.valueset.parameter.concepticon_id, - value.word.name, - self.obj.default_representation, - value.valueset.parameter.name, - refs, - value.valueset.comment or '', - value.valueset.alt_representation or '', - self.obj.alt_representation or '', - ] - - def includeme(config): config.register_adapter(GeoJsonMeaning, IParameter) - config.register_adapter(CldfDictionary, IContribution, ICldfDataset, name='cldf') config.register_adapter(GeoJsonDictionaries, IContribution, IIndex) diff --git a/ids/static/downloads.json b/ids/static/downloads.json new file mode 100644 index 0000000..150677e --- /dev/null +++ b/ids/static/downloads.json @@ -0,0 +1,20 @@ +{ + "1.0": { + "bitstreams": [ + { + "last-modified": 1516218189703, + "checksum": "f3cae525bb1cd246747e6b1c67c0ea65", + "created": 1516218189038, + "checksum-algorithm": "MD5", + "bitstreamid": "ids_dataset.cldf.zip", + "filesize": 9184372, + "content-type": "application/zip" + } + ], + "oid": "EAEA0-1575-B9D3-4DAF-0", + "metadata": { + "title": "ids 1.0 - downloads", + "creator": "pycdstar" + } + } +} \ No newline at end of file diff --git a/ids/tests/__init__.py b/ids/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ids/tests/test_functional.py b/ids/tests/test_functional.py index 4204c65..d32f192 100644 --- a/ids/tests/test_functional.py +++ b/ids/tests/test_functional.py @@ -1,40 +1,31 @@ -from clldutils.path import Path -from clld.tests.util import TestWithApp - -import ids - - -class Tests(TestWithApp): - __cfg__ = Path(ids.__file__).parent.joinpath('..', 'development.ini').resolve() - __setup_db__ = False - - def test_home(self): - self.app.get_html('/') - - def test_contribution(self): - self.app.get_html('/contributions') - self.app.get_html('/contributions/500') - self.app.get_html('/contributions.geojson') - self.app.get_dt('/contributions') - self.app.get_dt('/languages') - self.app.get_html('/contributions/215') - self.app.get_dt('/values?contribution=220&iSortingCols=1&iSortCol_0=0') - self.app.get_dt('/values?contribution=220&iSortingCols=1&iSortCol_0=2') - self.app.get_dt('/values?contribution=220&sSearch_0=1&sSearch_2=4') - - def test_contributor(self): - self.app.get_html('/contributors') - self.app.get_dt('/contributors') - - def test_parameter(self): - self.app.get_html('/parameters') - r = self.app.get_dt('/parameters') - assert r - self.app.get_dt('/parameters?chapter=1') - self.app.get_html('/parameters/1-222') - self.app.get_json('/parameters/1-222.geojson') - self.app.get_dt('/values?parameter=1-222') - - def test_language(self): - self.app.get_html('/languages/182.snippet.html') - self.app.get_html('/languages/128.snippet.html?parameter=962') +import pytest + +pytest_plugins = ['clld'] + + +@pytest.mark.parametrize( + "method,path", + [ + ('get_html', '/'), + ('get_html', '/contributions'), + ('get_html', '/contributions/500'), + ('get_html', '/contributions.geojson'), + ('get_dt', '/contributions'), + ('get_dt', '/languages'), + ('get_html', '/contributions/215'), + ('get_dt', '/values?contribution=220&iSortingCols=1&iSortCol_0=0'), + ('get_dt', '/values?contribution=220&iSortingCols=1&iSortCol_0=2'), + ('get_dt', '/values?contribution=220&sSearch_0=1&sSearch_2=4'), + ('get_html', '/contributors'), + ('get_dt', '/contributors'), + ('get_html', '/parameters'), + ('get_dt', '/parameters'), + ('get_dt', '/parameters?chapter=1'), + ('get_html', '/parameters/1-222'), + ('get_json', '/parameters/1-222.geojson'), + ('get_dt', '/values?parameter=1-222'), + ('get_html', '/languages/182.snippet.html'), + ('get_html', '/languages/128.snippet.html?parameter=962'), + ]) +def test_pages(app, method, path): + getattr(app, method)(path) diff --git a/migrations/versions/7ecffd0a3d77_update_unique_null.py b/migrations/versions/7ecffd0a3d77_update_unique_null.py new file mode 100644 index 0000000..cd14de4 --- /dev/null +++ b/migrations/versions/7ecffd0a3d77_update_unique_null.py @@ -0,0 +1,165 @@ +"""update unique null + +Revision ID: 7ecffd0a3d77 +Revises: 28accca7dc14 +Create Date: 2018-01-17 20:48:11.182247 + +""" + +# revision identifiers, used by Alembic. +revision = '7ecffd0a3d77' +down_revision = '28accca7dc14' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +UNIQUE_NULL = [ + ('contributioncontributor', + ['contribution_pk', 'contributor_pk'], []), + ('contributionreference', + ['contribution_pk', 'source_pk', 'description'], + ['description']), + ('domainelement', + ['parameter_pk', 'name'], + ['name']), + ('domainelement', + ['parameter_pk', 'number'], + ['number']), + ('editor', + ['dataset_pk', 'contributor_pk'], []), + ('languageidentifier', + ['language_pk', 'identifier_pk'], []), + ('languagesource', + ['language_pk', 'source_pk'], []), + ('sentencereference', + ['sentence_pk', 'source_pk', 'description'], + ['description']), + ('unit', + ['language_pk', 'id'], + ['id']), + ('unitdomainelement', + ['unitparameter_pk', 'name'], + ['name']), + ('unitdomainelement', + ['unitparameter_pk', 'ord'], + ['ord']), + # NOTE: can have multiple values and also multiple unitdomainelements + ('unitvalue', + ['unit_pk', 'unitparameter_pk', 'contribution_pk', 'name', 'unitdomainelement_pk'], + ['contribution_pk', 'name', 'unitdomainelement_pk']), + # NOTE: can have multiple values and also multiple domainelements + ('value', + ['valueset_pk', 'name', 'domainelement_pk'], + ['name', 'domainelement_pk']), + ('valuesentence', + ['value_pk', 'sentence_pk'], []), + ('valueset', + ['language_pk', 'parameter_pk', 'contribution_pk'], + ['contribution_pk']), + ('valuesetreference', + ['valueset_pk', 'source_pk', 'description'], + ['description']), +] + + +class DryRunException(Exception): + """Raised at the end of a dry run so the database transaction is not comitted.""" + + +def upgrade(dry=False, verbose=True): + conn = op.get_bind() + + assert conn.dialect.name == 'postgresql' + + def delete_null_duplicates(tablename, columns, notnull, returning=sa.text('*')): + assert columns + table = sa.table(tablename, *map(sa.column, ['pk'] + columns)) + any_null = sa.or_(table.c[n] == sa.null() for n in notnull) + yield table.delete(bind=conn).where(any_null).returning(returning) + other = table.alias() + yield table.delete(bind=conn).where(~any_null).returning(returning)\ + .where(sa.exists() + .where(sa.and_(table.c[c] == other.c[c] for c in columns)) + .where(table.c.pk > other.c.pk)) + + def print_rows(rows, verbose=verbose): + if not verbose: + return + for r in rows: + print(' %r' % dict(r)) + + class regclass(sa.types.UserDefinedType): + def get_col_spec(self): + return 'regclass' + + pga = sa.table('pg_attribute', *map(sa.column, ['attrelid', 'attname', 'attnum', 'attnotnull'])) + + select_nullable = sa.select([pga.c.attname], bind=conn)\ + .where(pga.c.attrelid == sa.cast(sa.bindparam('table'), regclass))\ + .where(pga.c.attname == sa.func.any(sa.bindparam('notnull')))\ + .where(~pga.c.attnotnull)\ + .order_by(pga.c.attnum) + + pgco = sa.table('pg_constraint', *map(sa.column, + ['oid', 'conname', 'contype', 'conrelid', 'conkey'])) + + sq = sa.select([ + pgco.c.conname.label('name'), + sa.func.pg_get_constraintdef(pgco.c.oid).label('definition'), + sa.func.array( + sa.select([sa.cast(pga.c.attname, sa.Text)]) + .where(pga.c.attrelid == pgco.c.conrelid) + .where(pga.c.attnum == sa.func.any(pgco.c.conkey)) + .as_scalar()).label('names'), + ]).where(pgco.c.contype == 'u')\ + .where(pgco.c.conrelid == sa.cast(sa.bindparam('table'), regclass))\ + .alias() + + select_const = sa.select([sq.c.name, sq.c.definition], bind=conn)\ + .where(sq.c.names.op('@>')(sa.bindparam('cols')))\ + .where(sq.c.names.op('<@')(sa.bindparam('cols'))) + + for table, unique, null in UNIQUE_NULL: + print(table) + notnull = [u for u in unique if u not in null] + delete_null, delete_duplicates = delete_null_duplicates(table, unique, notnull) + + nulls = delete_null.execute().fetchall() + if nulls: + print('%s delete %d row(s) violating NOT NULL(%s)' % (table, len(nulls), ', '.join(notnull))) + print_rows(nulls) + + duplicates = delete_duplicates.execute().fetchall() + if duplicates: + print('%s delete %d row(s) violating UNIQUE(%s)' % (table, len(duplicates), ', '.join(unique))) + print_rows(duplicates) + + for col, in select_nullable.execute(table=table, notnull=notnull): + print('%s alter column %s NOT NULL' % (table, col)) + op.alter_column(table, col, nullable=False) + + constraint = 'UNIQUE (%s)' % ', '.join(unique) + matching = select_const.execute(table=table, cols=unique).fetchall() + if matching: + assert len(matching) == 1 + (name, definition), = matching + if definition == constraint: + print('%s keep constraint %s %s\n' % (table, name, definition)) + continue + print('%s drop constraint %s %s' % (table, name, definition)) + op.drop_constraint(name, table) + name = '%s_%s_key' % (table, '_'.join(unique)) + print('%s create constraint %s %s' % (table, name, constraint)) + op.create_unique_constraint(name, table, unique) + print('') + + if dry: + raise DryRunException('set dry=False to apply these changes') + + + +def downgrade(): + pass diff --git a/setup.cfg b/setup.cfg index 4c79cbf..b7d7aa0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,11 @@ -[nosetests] -match = ^test -nocapture = 1 -cover-package = ids -with-coverage = 1 -cover-erase = 1 +[tool:pytest] +testpaths = ids +filterwarnings = + ignore::UserWarning + ignore::sqlalchemy.exc.SAWarning +addopts = + --cov=ids + --cov-report term-missing [compile_catalog] directory = ids/locale diff --git a/setup.py b/setup.py index a909842..3e793a2 100644 --- a/setup.py +++ b/setup.py @@ -19,17 +19,24 @@ include_package_data=True, zip_safe=False, install_requires=[ - 'clld>=3.2.4', - 'clldutils>=1.9', - 'clldmpg>=2.0.0', + 'clldmpg~=3.1', 'clld-glottologfamily-plugin>=1.3', - 'pycldf>=0.3.0', - ], - tests_require=[ - 'WebTest >= 1.3.1', # py3 compat - 'mock', - 'psycopg2', ], + extras_require={ + 'dev': ['flake8', 'waitress'], + 'test': [ + 'psycopg2', + 'tox', + 'mock', + 'pytest>=3.1', + 'pytest-clld', + 'pytest-mock', + 'pytest-cov', + 'coverage>=4.2', + 'selenium', + 'zope.component>=3.11.0', + ], + }, test_suite="ids", entry_points="""\ [paste.app_factory] diff --git a/tox.ini b/tox.ini index d9c395d..364c5ea 100644 --- a/tox.ini +++ b/tox.ini @@ -4,12 +4,20 @@ envlist = [testenv] commands = - python setup.py develop - python setup.py nosetests --with-coverage --cover-package=ids --cover-erase -deps = nosexcover + python -m pytest {posargs} +deps = + pytest + mock + psycopg2 + coverage + pytest-clld + pytest-cov + pytest-mock + webtest [testenv:py34] basepython = python3.4 [testenv:py27] basepython = python2.7 +