diff --git a/test/asynchronous/qcheck.py b/test/asynchronous/qcheck.py new file mode 100644 index 0000000000..190a7f1a91 --- /dev/null +++ b/test/asynchronous/qcheck.py @@ -0,0 +1,255 @@ +# Copyright 2009-present MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import datetime +import random +import re +import sys +import traceback + +sys.path[0:0] = [""] + +from bson.dbref import DBRef +from bson.objectid import ObjectId +from bson.son import SON + +_IS_SYNC = False + +gen_target = 100 +reduction_attempts = 10 +examples = 5 + + +def lift(value): + return lambda: value + + +def choose_lifted(generator_list): + return lambda: random.choice(generator_list) + + +def my_map(generator, function): + return lambda: function(generator()) + + +def choose(list): + return lambda: random.choice(list)() + + +def gen_range(start, stop): + return lambda: random.randint(start, stop) + + +def gen_int(): + max_int = 2147483647 + return lambda: random.randint(-max_int - 1, max_int) + + +def gen_float(): + return lambda: (random.random() - 0.5) * sys.maxsize + + +def gen_boolean(): + return lambda: random.choice([True, False]) + + +def gen_printable_char(): + return lambda: chr(random.randint(32, 126)) + + +def gen_printable_string(gen_length): + return lambda: "".join(gen_list(gen_printable_char(), gen_length)()) + + +def gen_char(set=None): + return lambda: bytes([random.randint(0, 255)]) + + +def gen_string(gen_length): + return lambda: b"".join(gen_list(gen_char(), gen_length)()) + + +def gen_unichar(): + return lambda: chr(random.randint(1, 0xFFF)) + + +def gen_unicode(gen_length): + return lambda: "".join([x for x in gen_list(gen_unichar(), gen_length)() if x not in ".$"]) + + +def gen_list(generator, gen_length): + return lambda: [generator() for _ in range(gen_length())] + + +def gen_datetime(): + return lambda: datetime.datetime( + random.randint(1970, 2037), + random.randint(1, 12), + random.randint(1, 28), + random.randint(0, 23), + random.randint(0, 59), + random.randint(0, 59), + random.randint(0, 999) * 1000, + ) + + +def gen_dict(gen_key, gen_value, gen_length): + def a_dict(gen_key, gen_value, length): + result = {} + for _ in range(length): + result[gen_key()] = gen_value() + return result + + return lambda: a_dict(gen_key, gen_value, gen_length()) + + +def gen_regexp(gen_length): + # TODO our patterns only consist of one letter. + # this is because of a bug in CPython's regex equality testing, + # which I haven't quite tracked down, so I'm just ignoring it... + def pattern(): + return "".join(gen_list(choose_lifted("a"), gen_length)()) + + def gen_flags(): + flags = 0 + if random.random() > 0.5: + flags = flags | re.IGNORECASE + if random.random() > 0.5: + flags = flags | re.MULTILINE + if random.random() > 0.5: + flags = flags | re.VERBOSE + + return flags + + return lambda: re.compile(pattern(), gen_flags()) + + +def gen_objectid(): + return lambda: ObjectId() + + +def gen_dbref(): + collection = gen_unicode(gen_range(0, 20)) + return lambda: DBRef(collection(), gen_mongo_value(1, True)()) + + +def gen_mongo_value(depth, ref): + choices = [ + gen_unicode(gen_range(0, 50)), + gen_printable_string(gen_range(0, 50)), + my_map(gen_string(gen_range(0, 1000)), bytes), + gen_int(), + gen_float(), + gen_boolean(), + gen_datetime(), + gen_objectid(), + lift(None), + ] + if ref: + choices.append(gen_dbref()) + if depth > 0: + choices.append(gen_mongo_list(depth, ref)) + choices.append(gen_mongo_dict(depth, ref)) + return choose(choices) + + +def gen_mongo_list(depth, ref): + return gen_list(gen_mongo_value(depth - 1, ref), gen_range(0, 10)) + + +def gen_mongo_dict(depth, ref=True): + return my_map( + gen_dict(gen_unicode(gen_range(0, 20)), gen_mongo_value(depth - 1, ref), gen_range(0, 10)), + SON, + ) + + +def simplify(case): # TODO this is a hack + if isinstance(case, SON) and "$ref" not in case: + simplified = SON(case) # make a copy! + if random.choice([True, False]): + # delete + simplified_keys = list(simplified) + if not len(simplified_keys): + return (False, case) + simplified.pop(random.choice(simplified_keys)) + return (True, simplified) + else: + # simplify a value + simplified_items = list(simplified.items()) + if not len(simplified_items): + return (False, case) + (key, value) = random.choice(simplified_items) + (success, value) = simplify(value) + simplified[key] = value + return (success, success and simplified or case) + if isinstance(case, list): + simplified = list(case) + if random.choice([True, False]): + # delete + if not len(simplified): + return (False, case) + simplified.pop(random.randrange(len(simplified))) + return (True, simplified) + else: + # simplify an item + if not len(simplified): + return (False, case) + index = random.randrange(len(simplified)) + (success, value) = simplify(simplified[index]) + simplified[index] = value + return (success, success and simplified or case) + return (False, case) + + +async def reduce(case, predicate, reductions=0): + for _ in range(reduction_attempts): + (reduced, simplified) = simplify(case) + if reduced and not await predicate(simplified): + return await reduce(simplified, predicate, reductions + 1) + return (reductions, case) + + +async def isnt(predicate): + async def is_not(x): + return not await predicate(x) + + return is_not + + +async def check(predicate, generator): + counter_examples = [] + for _ in range(gen_target): + case = generator() + try: + if not await predicate(case): + reduction = await reduce(case, predicate) + counter_examples.append("after {} reductions: {!r}".format(*reduction)) + except: + counter_examples.append(f"{case!r} : {traceback.format_exc()}") + return counter_examples + + +async def check_unittest(test, predicate, generator): + counter_examples = await check(predicate, generator) + if counter_examples: + failures = len(counter_examples) + message = "\n".join([" -> %s" % f for f in counter_examples[:examples]]) + message = "found %d counter examples, displaying first %d:\n%s" % ( + failures, + min(failures, examples), + message, + ) + test.fail(message) diff --git a/test/asynchronous/test_grid_file.py b/test/asynchronous/test_grid_file.py index 7071fc76f4..6d589dc01c 100644 --- a/test/asynchronous/test_grid_file.py +++ b/test/asynchronous/test_grid_file.py @@ -21,17 +21,21 @@ import sys import zipfile from io import BytesIO -from test.asynchronous import AsyncIntegrationTest, AsyncUnitTest, async_client_context +from test.asynchronous import ( + AsyncIntegrationTest, + AsyncUnitTest, + async_client_context, + qcheck, + unittest, +) from pymongo.asynchronous.database import AsyncDatabase sys.path[0:0] = [""] -from test import IntegrationTest, qcheck, unittest -from test.utils import EventListener, async_rs_or_single_client, rs_or_single_client +from test.utils import EventListener, async_rs_or_single_client from bson.objectid import ObjectId -from gridfs import GridFS from gridfs.asynchronous.grid_file import ( _SEEK_CUR, _SEEK_END, @@ -44,7 +48,7 @@ from gridfs.errors import NoFile from pymongo import AsyncMongoClient from pymongo.asynchronous.helpers import aiter, anext -from pymongo.errors import ConfigurationError, InvalidOperation, ServerSelectionTimeoutError +from pymongo.errors import ConfigurationError, ServerSelectionTimeoutError from pymongo.message import _CursorAddress _IS_SYNC = False @@ -407,8 +411,6 @@ async def test_multi_chunk_file(self): g = AsyncGridOut(self.db.fs, f._id) self.assertEqual(random_string, await g.read()) - # TODO: https://jira.mongodb.org/browse/PYTHON-4708 - @async_client_context.require_sync async def test_small_chunks(self): self.files = 0 self.chunks = 0 @@ -431,7 +433,7 @@ async def helper(data): self.assertEqual(data, await g.read(10) + await g.read(10)) return True - qcheck.check_unittest(self, helper, qcheck.gen_string(qcheck.gen_range(0, 20))) + await qcheck.check_unittest(self, helper, qcheck.gen_string(qcheck.gen_range(0, 20))) async def test_seek(self): f = AsyncGridIn(self.db.fs, chunkSize=3) diff --git a/test/qcheck.py b/test/qcheck.py index 8339bc3763..842580cbff 100644 --- a/test/qcheck.py +++ b/test/qcheck.py @@ -25,6 +25,8 @@ from bson.objectid import ObjectId from bson.son import SON +_IS_SYNC = True + gen_target = 100 reduction_attempts = 10 examples = 5 @@ -221,7 +223,10 @@ def reduce(case, predicate, reductions=0): def isnt(predicate): - return lambda x: not predicate(x) + def is_not(x): + return not predicate(x) + + return is_not def check(predicate, generator): diff --git a/test/test_grid_file.py b/test/test_grid_file.py index 0e806eb5cb..bd89235b73 100644 --- a/test/test_grid_file.py +++ b/test/test_grid_file.py @@ -21,17 +21,21 @@ import sys import zipfile from io import BytesIO -from test import IntegrationTest, UnitTest, client_context +from test import ( + IntegrationTest, + UnitTest, + client_context, + qcheck, + unittest, +) from pymongo.synchronous.database import Database sys.path[0:0] = [""] -from test import IntegrationTest, qcheck, unittest from test.utils import EventListener, rs_or_single_client from bson.objectid import ObjectId -from gridfs import GridFS from gridfs.errors import NoFile from gridfs.synchronous.grid_file import ( _SEEK_CUR, @@ -43,7 +47,7 @@ GridOutCursor, ) from pymongo import MongoClient -from pymongo.errors import ConfigurationError, InvalidOperation, ServerSelectionTimeoutError +from pymongo.errors import ConfigurationError, ServerSelectionTimeoutError from pymongo.message import _CursorAddress from pymongo.synchronous.helpers import iter, next @@ -405,8 +409,6 @@ def test_multi_chunk_file(self): g = GridOut(self.db.fs, f._id) self.assertEqual(random_string, g.read()) - # TODO: https://jira.mongodb.org/browse/PYTHON-4708 - @client_context.require_sync def test_small_chunks(self): self.files = 0 self.chunks = 0 diff --git a/tools/synchro.py b/tools/synchro.py index f4019f0bbb..dfe3854e22 100644 --- a/tools/synchro.py +++ b/tools/synchro.py @@ -159,6 +159,7 @@ "conftest.py", "pymongo_mocks.py", "utils_spec_runner.py", + "qcheck.py", "test_bulk.py", "test_client.py", "test_client_bulk_write.py",