diff --git a/.travis.yml b/.travis.yml index 821560a..ad7b37c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,18 +1,12 @@ -dist: trusty -sudo: required language: python matrix: include: - - python: 3.5 - env: TOXENV=py35 - - python: 3.6 - env: TOXENV=py36 - - python: pypy3 - env: TOXENV=pypy - - python: 3.6 + - python: 3.7 + env: TOXENV=py37 + - python: 3.7 env: TOXENV=openapi - - python: 3.6 + - python: 3.7 env: TOXENV=docs services: diff --git a/setup.py b/setup.py index ae66112..e3ee601 100644 --- a/setup.py +++ b/setup.py @@ -45,14 +45,14 @@ def find_packages(namespace): install_requires=[ 'aiohttp >= 3.0.0, < 4', 'cerberus >= 0.9.2', - 'motor >= 1.1', + 'motor >= 2.0', 'python-jose >= 1.3.2', 'python-decouple >= 3.1', 'werkzeug >= 0.11.4', 'picobox >= 2.0', ], tests_require=[ - 'pytest >= 2.8.7', + 'pytest >= 4.0.0', 'pytest-aiohttp >= 0.3.0', ], entry_points={ diff --git a/tests/conftest.py b/tests/conftest.py index bb19040..1faed97 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,10 +58,12 @@ async def testapp(request, aiohttp_client, testconf, testdatabase): ) -def pytest_namespace(): +def pytest_configure(): # Expose some internally used helpers via 'pytest' module to make it - # available everywhere without making modules and packages. - return {'regex': _pytest_regex} + # available everywhere without making modules and packages. We used + # to have 'pytest_namespace' hook, however, it's been removed since + # pytest 4.0 and thus we need this line for backward compatibility. + pytest.regex = _pytest_regex class _pytest_regex: diff --git a/tests/resources/test_snippets.py b/tests/resources/test_snippets.py index b3bd5ae..b7fad95 100644 --- a/tests/resources/test_snippets.py +++ b/tests/resources/test_snippets.py @@ -51,6 +51,7 @@ def __repr__(self): async def snippets(testdatabase): snippets = [ { + '_id': 1, 'title': 'snippet #1', 'changesets': [ {'content': 'def foo(): pass'}, @@ -61,6 +62,7 @@ async def snippets(testdatabase): 'updated_at': datetime.datetime(2018, 1, 24, 22, 26, 35), }, { + '_id': 2, 'title': 'snippet #2', 'changesets': [ {'content': 'int do_something() {}'}, @@ -76,9 +78,9 @@ async def snippets(testdatabase): # in-place. However, due to our custom SON manipulators it's not the # case in our case, since one of them makes a shallow copy which # basically results in no changes in the updated documents. - ids = await testdatabase.snippets.insert(snippets) + result = await testdatabase.snippets.insert_many(snippets) - for id_, snippet in zip(ids, snippets): + for id_, snippet in zip(result.inserted_ids, snippets): # One of SON manipulators we use converts 'id' into '_id' during # inserts/updates, and vice versa during reads. That's why we use # human readable 'id' and not '_id'. @@ -288,7 +290,7 @@ async def test_pagination_links(testapp, testdatabase): now = datetime.datetime.utcnow().replace(microsecond=0) snippets = [ { - 'id': i + 1, + '_id': i + 1, 'title': 'snippet #%d' % (i + 1), 'changesets': [ {'content': '(println "Hello, World!")'}, @@ -300,7 +302,7 @@ async def test_pagination_links(testapp, testdatabase): } for i in range(10) ] - await testdatabase.snippets.insert(snippets) + await testdatabase.snippets.insert_many(snippets) # We should have seen snippets with ids 10, 9 and 8. No link to the prev # page, as we are at the very beginning of the list @@ -349,7 +351,7 @@ async def test_pagination_links_one_page_larger_than_whole_list(testapp, testdat now = datetime.datetime.utcnow().replace(microsecond=0) snippets = [ { - 'id': i + 1, + '_id': i + 1, 'title': 'snippet #%d' % (i + 1), 'changesets': [ {'content': '(println "Hello, World!")'}, @@ -361,7 +363,7 @@ async def test_pagination_links_one_page_larger_than_whole_list(testapp, testdat } for i in range(10) ] - await testdatabase.snippets.insert(snippets) + await testdatabase.snippets.insert_many(snippets) # Default limit is 20 and there no prev/next pages - only the first one resp = await _get_next_page(testapp, limit=None) @@ -395,7 +397,7 @@ async def test_pagination_links_num_of_items_is_multiple_of_pages(testapp, testd now = datetime.datetime.utcnow().replace(microsecond=0) snippets = [ { - 'id': i + 1, + '_id': i + 1, 'title': 'snippet #%d' % (i + 1), 'changesets': [ {'content': '(println "Hello, World!")'}, @@ -407,7 +409,7 @@ async def test_pagination_links_num_of_items_is_multiple_of_pages(testapp, testd } for i in range(12) ] - await testdatabase.snippets.insert(snippets) + await testdatabase.snippets.insert_many(snippets) # We should have seen snippets with ids 12, 11, 10 and 9. No link to the # prev page, as we are at the very beginning of the list @@ -445,7 +447,7 @@ async def test_pagination_links_non_consecutive_ids(testapp, testdatabase): now = datetime.datetime.utcnow().replace(microsecond=0) snippets = [ { - 'id': i, + '_id': i, 'title': 'snippet #%d' % i, 'changesets': [ {'content': '(println "Hello, World!")'}, @@ -457,7 +459,7 @@ async def test_pagination_links_non_consecutive_ids(testapp, testdatabase): } for i in [1, 7, 17, 23, 24, 29, 31, 87, 93, 104] ] - await testdatabase.snippets.insert(snippets) + await testdatabase.snippets.insert_many(snippets) resp1 = await _get_next_page(testapp, limit=3) expected_link1 = ( @@ -514,8 +516,8 @@ async def test_get_snippets_pagination_not_found(testapp): 'content': 'def foo(): pass', 'syntax': None, 'tags': [], - 'created_at': pytest.regex('\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'), - 'updated_at': pytest.regex('\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}')}), + 'created_at': pytest.regex(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'), + 'updated_at': pytest.regex(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}')}), ({'title': 'snippet #1', 'content': 'def foo(): pass', @@ -526,8 +528,8 @@ async def test_get_snippets_pagination_not_found(testapp): 'content': 'def foo(): pass', 'syntax': 'python', 'tags': ['tag_a', 'tag_b'], - 'created_at': pytest.regex('\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'), - 'updated_at': pytest.regex('\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}')}), + 'created_at': pytest.regex(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'), + 'updated_at': pytest.regex(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}')}), ]) async def test_post_snippet(testapp, testconf, snippet, rv): testconf['SNIPPET_SYNTAXES'] = ['python', 'clojure'] @@ -654,8 +656,8 @@ async def test_delete_snippet_bad_request(testapp): 'content': 'def foo(): pass', 'syntax': None, 'tags': [], - 'created_at': pytest.regex('\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'), - 'updated_at': pytest.regex('\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}')}), + 'created_at': pytest.regex(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'), + 'updated_at': pytest.regex(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}')}), ({'title': 'snippet #1', 'content': 'def foo(): pass', @@ -666,8 +668,8 @@ async def test_delete_snippet_bad_request(testapp): 'content': 'def foo(): pass', 'syntax': 'python', 'tags': ['tag_a', 'tag_b'], - 'created_at': pytest.regex('\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'), - 'updated_at': pytest.regex('\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}')}), + 'created_at': pytest.regex(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'), + 'updated_at': pytest.regex(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}')}), ]) async def test_put_snippet(testapp, snippets, snippet, rv): resp = await testapp.put('/v1/snippets/1', data=json.dumps(snippet)) @@ -750,7 +752,7 @@ async def test_patch_snippet(testapp, snippets): 'syntax': 'python', 'tags': ['tag_a', 'tag_b'], 'created_at': '2018-01-24T22:26:35', - 'updated_at': pytest.regex('\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'), + 'updated_at': pytest.regex(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'), } diff --git a/xsnippet/api/database.py b/xsnippet/api/database.py index eb3a217..5731263 100644 --- a/xsnippet/api/database.py +++ b/xsnippet/api/database.py @@ -28,19 +28,7 @@ def create_connection(conf): mongo = AsyncIOMotorClient(conf['DATABASE_CONNECTION_URI']) # get_default_database returns a database from the connection string - db = mongo.get_default_database() - - # ID incrementer is used to auto increment record ID if nothing - # is explicitly passed. - # - # ID processor is used for auto converting domain IDs to and from - # database ones (i.e. 'id' -> '_id' and vice versa). - # - # SON manipulators are applied in reversed order. - db.add_son_manipulator(_IdIncrementer()) - db.add_son_manipulator(_IdProcessor()) - - return db + return mongo.get_database() async def setup(app, db): diff --git a/xsnippet/api/resources/snippets.py b/xsnippet/api/resources/snippets.py index 75deed8..7040aea 100644 --- a/xsnippet/api/resources/snippets.py +++ b/xsnippet/api/resources/snippets.py @@ -39,7 +39,7 @@ async def _write(resource, service_fn, *, status, conf): 'content': {'type': 'string', 'required': True, 'empty': False}, 'syntax': {'type': 'string'}, 'tags': {'type': 'list', - 'schema': {'type': 'string', 'regex': '[\w_-]+'}}, + 'schema': {'type': 'string', 'regex': r'[\w_-]+'}}, 'created_at': {'type': 'datetime', 'readonly': True}, 'updated_at': {'type': 'datetime', 'readonly': True}, }) @@ -183,7 +183,7 @@ async def get(self, conf): }, 'tag': { 'type': 'string', - 'regex': '[\w_-]+', + 'regex': r'[\w_-]+', }, 'syntax': { 'type': 'string', diff --git a/xsnippet/api/services/snippet.py b/xsnippet/api/services/snippet.py index 201e673..e70546b 100644 --- a/xsnippet/api/services/snippet.py +++ b/xsnippet/api/services/snippet.py @@ -61,8 +61,15 @@ async def create(self, snippet): snippet['created_at'] = now snippet['updated_at'] = now - snippet_id = await self.db.snippets.insert(snippet) - snippet['id'] = snippet_id + resolved = await self.db['_autoincrement_ids'].find_one_and_update( + {'_id': 'snippets'}, + update={'$inc': {'next': 1}}, + upsert=True, + new=True) + snippet['_id'] = resolved['next'] + + await self.db.snippets.insert_one(snippet) + snippet['id'] = snippet.pop('_id') snippet['content'] = snippet.pop('changesets', [])[0]['content'] return snippet @@ -81,12 +88,12 @@ async def update(self, snippet): } } - result = await self.db.snippets.update( + result = await self.db.snippets.update_one( {'_id': snippet['id']}, parameters, ) - if not result['n']: + if result.matched_count == 0: raise exceptions.SnippetNotFound( 'Sorry, cannot find the requested snippet.') @@ -118,7 +125,7 @@ async def get(self, *, title=None, tag=None, syntax=None, limit=100, condition['$and'] = [ {'created_at': {filters['created_at']: specimen['created_at']}}, - {'_id': {filters['_id']: specimen['id']}}, + {'_id': {filters['_id']: specimen['_id']}}, ] # use a compound sorting key (created_at, _id) to avoid the ambiguity @@ -134,6 +141,7 @@ async def get(self, *, title=None, tag=None, syntax=None, limit=100, snippets = await query.limit(limit).to_list(None) for snippet in snippets: + snippet['id'] = snippet.pop('_id') snippet['content'] = snippet.pop('changesets', [])[-1]['content'] return snippets @@ -144,12 +152,13 @@ async def get_one(self, id): raise exceptions.SnippetNotFound( 'Sorry, cannot find the requested snippet.') + snippet['id'] = snippet.pop('_id') snippet['content'] = snippet.pop('changesets', [])[-1]['content'] return snippet async def delete(self, id): - result = await self.db.snippets.remove({'_id': id}) - if not result['n']: + result = await self.db.snippets.delete_one({'_id': id}) + if result.deleted_count == 0: raise exceptions.SnippetNotFound( 'Sorry, cannot find the requested snippet.')