userver: en/testsuite/databases/mongo/pytest_plugin.py Source File
Loading...
Searching...
No Matches
pytest_plugin.py
1import contextlib
2import dataclasses
3import multiprocessing.pool
4import pathlib
5import pprint
6import random
7import re
8
9import pymongo
10import pymongo.collection
11import pymongo.errors
12import pytest
13from bson import json_util
14
15from testsuite import types, utils
16
17from . import connection, ensure_db_indexes, mongo_schema, service
18
19# pylint: disable=too-many-statements
20
21DB_FILE_RE_PATTERN = re.compile(r'^db_(?P<mongo_db_alias>\w+)\.json$')
22JSON_OPTIONS = json_util.JSONOptions(tz_aware=False)
23MONGO_OBJECT_HOOKS = (
24 '$binary',
25 '$code',
26 '$date',
27 '$dbPointer',
28 '$maxKey',
29 '$minKey',
30 '$numberDecimal',
31 '$numberDouble',
32 '$numberInt',
33 '$numberLong',
34 '$oid',
35 '$ref',
36 '$regex',
37 '$regularExpression',
38 '$symbol',
39 '$timestamp',
40 '$undefined',
41 '$uuid',
42)
43
44
45class BaseError(Exception):
46 """Base testsuite error"""
47
48
49class UnknownCollectionError(BaseError):
50 pass
51
52
53class CollectionWrapper:
54 def __init__(self, collections):
55 # TODO: deprecate collection as attribute
56 for alias, collection in collections.items():
57 setattr(self, alias, collection)
58 self._collections = collections.copy()
59 self._aliases = tuple(collections.keys())
60
61 def __getitem__(self, alias: str) -> pymongo.collection.Collection:
62 return self._collections[alias]
63
64 def __contains__(self, alias: str) -> bool:
65 return alias in self._collections
66
67 def get_aliases(self) -> tuple[str]:
68 return self._aliases
69
70
71class CollectionWrapperFactory:
72 def __init__(self, connection_info: connection.ConnectionInfo):
73 self._connection_info = connection_info
74
75 @property
76 def connection_string(self) -> str:
77 return self._connection_info.get_uri()
78
79 @utils.cached_property
80 def client(self) -> pymongo.MongoClient:
81 return pymongo.MongoClient(self.connection_string)
82
83 def create_collection_wrapper(
84 self,
85 collection_names,
86 mongodb_settings,
87 ) -> CollectionWrapper:
88 collections = {}
89 for name in collection_names:
90 if name not in mongodb_settings:
91 raise UnknownCollectionError(
92 f'Missing collection {name} in mongodb_settings fixture',
93 )
94 # pylint: disable=unsubscriptable-object
95 settings = mongodb_settings[name]['settings']
96 database = self.client[settings['database']]
97 collections[name] = database[settings['collection']]
98 return CollectionWrapper(collections)
99
100
101def pytest_configure(config):
102 config.addinivalue_line(
103 'markers',
104 'noshuffledb: disable data set shuffle for marked test',
105 )
106 config.addinivalue_line(
107 'markers',
108 'filldb: specify mongo static file suffix',
109 )
110 config.addinivalue_line(
111 'markers',
112 'mongodb_collections: override mongo collections list',
113 )
114
115
116def pytest_addoption(parser):
117 """
118 :param parser: pytest's argument parser
119 """
120 group = parser.getgroup('mongo')
121 group.addoption('--mongo', help='Mongo connection string.')
122 group.addoption(
123 '--no-indexes',
124 action='store_true',
125 help='Disable index creation.',
126 )
127 group.addoption(
128 '--no-shuffle-db',
129 action='store_true',
130 help='Disable fixture data shuffle.',
131 )
132 group.addoption(
133 '--no-sharding',
134 action='store_true',
135 help='Disable collections sharding.',
136 )
137 group.addoption(
138 '--no-mongo',
139 help='Disable mongo startup',
140 action='store_true',
141 )
142 parser.addini(
143 'mongo-retry-writes',
144 type='bool',
145 default=False,
146 help=(
147 "Controls value of 'retryWrites' parameter of mongo connection "
148 'string.'
149 ),
150 )
151
152
153def pytest_report_header(config):
154 conninfo = _get_connection_info(config)
155 return [f'Mongo: {conninfo.get_uri()}']
156
157
158def pytest_service_register(register_service):
159 register_service('mongo', service.create_mongo_service)
160
161
162def pytest_register_object_hooks():
163 return {key: _mongo_object_hook for key in MONGO_OBJECT_HOOKS}
164
165
166@pytest.fixture
167def mongodb(
168 mongodb_init,
169 _mongodb_local: CollectionWrapper,
170) -> CollectionWrapper:
171 """Returns MongoDB collections wrapper for the test.
172
173 Collections are initialized by the @c mongodb_init fixture.
174
175 @ingroup userver_testsuite_fixtures
176 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/databases/mongo/pytest_plugin.py#L167)
177 """
178 return _mongodb_local
179
180
181@pytest.fixture
182def mongo_connections(
183 mongodb_settings,
184 mongo_connection_info,
185 mongo_extra_connections,
186 _mongo_local_collections,
187) -> dict[str, str]:
188 mongo_connection_uri = mongo_connection_info.get_uri()
189 return {
190 **{
191 mongodb_settings[name]['settings'][
192 'connection'
193 ]: mongo_connection_uri
194 for name in _mongo_local_collections
195 },
196 **{
197 extra_conn: mongo_connection_uri
198 for extra_conn in mongo_extra_connections
199 },
200 }
201
202
203@pytest.fixture
204def mongo_extra_connections() -> tuple[str, ...]:
205 """
206 Override this if you need to access mongo connections besides those
207 defined in mongo_connections fixture
208
209 @ingroup userver_testsuite_fixtures
210 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/databases/mongo/pytest_plugin.py#L201)
211 """
212 return ()
213
214
215@pytest.fixture(scope='session')
216def mongo_connection_info(
217 pytestconfig,
218) -> connection.ConnectionInfo:
219 return _get_connection_info(pytestconfig)
220
221
222@pytest.fixture
223def mongodb_settings(
224 mongo_schema_directory,
225 mongo_schema_extra_directories,
226 _mongo_schema_cache,
227) -> mongo_schema.MongoSchemas:
228 return mongo_schema.MongoSchemas(
229 _mongo_schema_cache,
230 (mongo_schema_directory, *mongo_schema_extra_directories),
231 )
232
233
234@pytest.fixture
235def mongodb_collections(mongodb_settings) -> tuple[str, ...]:
236 """
237 Override this to enable access to named collections within test module
238
239 Returns all available collections by default.
240
241 @ingroup userver_testsuite_fixtures
242 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/databases/mongo/pytest_plugin.py#L229)
243 """
244 return tuple(mongodb_settings.keys())
245
246
247@pytest.fixture(scope='session')
248def mongo_schema_extra_directories() -> tuple[str, ...]:
249 """
250 Override to use collection schemas besides those defined by
251 @c mongo_schema_directory fixture
252
253 @ingroup userver_testsuite_fixtures
254 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/databases/mongo/pytest_plugin.py#L239)
255 """
256 return ()
257
258
259@pytest.fixture(scope='session')
260def _mongo_indexes_ensured() -> set[str]:
261 return set()
262
263
264@pytest.fixture
265def _mongo_service(
266 pytestconfig,
267 ensure_service_started,
268 _mongodb_local,
269 _mongo_service_settings,
270) -> None:
271 aliases = _mongodb_local.get_aliases()
272 if (
273 aliases
274 and not pytestconfig.option.mongo
275 and not pytestconfig.option.no_mongo
276 ):
277 ensure_service_started('mongo', settings=_mongo_service_settings)
278
279
280@pytest.fixture
281def _mongo_create_indexes(
282 _mongodb_local,
283 mongodb_settings,
284 pytestconfig,
285 _mongo_indexes_ensured,
286 _mongo_service,
287) -> None:
288 aliases = _mongodb_local.get_aliases()
289 if not pytestconfig.option.no_indexes:
290 _ensure_indexes = {}
291 for alias in aliases:
292 if (
293 alias not in _mongo_indexes_ensured
294 and alias in mongodb_settings
295 ):
296 _ensure_indexes[alias] = mongodb_settings[alias]
297 if _ensure_indexes:
298 sharding_enabled = not pytestconfig.option.no_sharding
299 ensure_db_indexes.ensure_db_indexes(
300 _mongodb_local,
301 _ensure_indexes,
302 sharding_enabled=sharding_enabled,
303 )
304 _mongo_indexes_ensured.update(_ensure_indexes)
305
306
307@pytest.fixture(scope='session')
308def _mongo_thread_pool() -> types.YieldFixture[
309 multiprocessing.pool.ThreadPool,
310]:
311 pool = multiprocessing.pool.ThreadPool(processes=1)
312 with contextlib.closing(pool):
313 yield pool
314
315
316@pytest.fixture
317def _mongo_query_loader(load_json):
318 def loader(filename, missing_ok=False):
319 data = load_json(filename, missing_ok=missing_ok)
320 if data is None:
321 return []
322 return data
323
324 return loader
325
326
327@pytest.fixture
328def mongodb_init(
329 request,
330 verify_file_paths,
331 static_dir: pathlib.Path,
332 _mongodb_local,
333 _mongo_thread_pool,
334 _mongo_create_indexes,
335 _mongo_query_loader,
336) -> None:
337 """Populate mongodb with fixture data.
338
339 @ingroup userver_testsuite_fixtures
340 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/databases/mongo/pytest_plugin.py#L316)
341 """
342
343 if request.node.get_closest_marker('nofilldb'):
344 return
345
346 # Disable shuffle to make some buggy test work
347 shuffle_enabled = (
348 not request.config.option.no_shuffle_db
349 and not request.node.get_closest_marker('noshuffledb')
350 )
351 aliases = {key: key for key in _mongodb_local.get_aliases()}
352 requested = set()
353
354 for marker in request.node.iter_markers('filldb'):
355 for dbname, alias in marker.kwargs.items():
356 if dbname not in aliases:
357 raise UnknownCollectionError(
358 f'Unknown collection {dbname} requested'
359 )
360 if alias != 'default':
361 aliases[dbname] = '{}_{}'.format(dbname, alias)
362 requested.add(dbname)
363
364 def _verify_db_alias(file_path: pathlib.Path) -> bool:
365 if not _is_relevant_file(request, static_dir, file_path):
366 return True
367 match = DB_FILE_RE_PATTERN.search(file_path.name)
368 if match:
369 db_alias = match.group('mongo_db_alias')
370 if db_alias not in aliases and not any(
371 db_alias.startswith(alias + '_') for alias in aliases
372 ):
373 return False
374 return True
375
376 verify_file_paths(
377 _verify_db_alias,
378 check_name='mongo_db_aliases',
379 text_at_fail='file has not valid mongo collection name alias '
380 '(probably should add to service.yaml)',
381 )
382
383 def load_collection(params):
384 dbname, alias = params
385 try:
386 col = getattr(_mongodb_local, dbname)
387 except AttributeError:
388 return
389
390 docs = _mongo_query_loader(
391 f'db_{alias}.json', missing_ok=dbname not in requested
392 )
393
394 if not docs and col.find_one({}, []) is None:
395 return
396
397 if shuffle_enabled:
398 # Make sure there is no tests that depend on order of
399 # documents in fixture file.
400 random.shuffle(docs)
401
402 try:
403 col.bulk_write(
404 [
405 pymongo.DeleteMany({}),
406 *(pymongo.InsertOne(doc) for doc in docs),
407 ],
408 ordered=True,
409 )
410 except pymongo.errors.BulkWriteError as bwe:
411 pprint.pprint(bwe.details)
412 raise
413
414 pool_args = []
415 for dbname, alias in aliases.items():
416 pool_args.append((dbname, alias))
417
418 _mongo_thread_pool.map(load_collection, pool_args)
419
420
421@pytest.fixture
422def _mongodb_local(
423 mongodb_settings,
424 _mongo_local_collections,
425 _mongo_collection_wrapper_factory: CollectionWrapperFactory,
426) -> CollectionWrapper:
427 return _mongo_collection_wrapper_factory.create_collection_wrapper(
428 _mongo_local_collections,
429 mongodb_settings,
430 )
431
432
433@pytest.fixture(scope='session')
434def _mongo_collection_wrapper_factory(
435 mongo_connection_info: connection.ConnectionInfo,
436) -> CollectionWrapperFactory:
437 return CollectionWrapperFactory(mongo_connection_info)
438
439
440@pytest.fixture
441def _mongo_local_collections(request, mongodb_collections) -> set[str]:
442 result = set(mongodb_collections)
443 for marker in request.node.iter_markers('mongodb_collections'):
444 result.update(marker.args)
445 return result
446
447
448@pytest.fixture(scope='session')
449def _mongo_schema_cache() -> mongo_schema.MongoSchemaCache:
450 return mongo_schema.MongoSchemaCache()
451
452
453@pytest.fixture(scope='session')
454def _mongo_service_settings(
455 pytestconfig,
456) -> service.ServiceSettings | None:
457 if pytestconfig.option.mongo:
458 return None
459 return service.get_service_settings()
460
461
462def _is_relevant_file(
463 request,
464 static_dir: pathlib.Path,
465 file_path: pathlib.Path,
466) -> bool:
467 default_static_dir = static_dir / 'default'
468 module_static_dir = static_dir / pathlib.Path(request.fspath).stem
469 return _is_nested_path(file_path, default_static_dir) or _is_nested_path(
470 file_path,
471 module_static_dir,
472 )
473
474
475def _is_nested_path(parent: pathlib.Path, nested: pathlib.Path) -> bool:
476 try:
477 pathlib.PurePath(nested).relative_to(parent)
478 return True
479 except ValueError:
480 return False
481
482
483def _mongo_object_hook(doc):
484 return json_util.object_hook(doc, JSON_OPTIONS)
485
486
487def _get_connection_info(config):
488 # External mongo instance
489 if config.option.mongo:
490 return connection.parse_connection_uri(config.option.mongo)
491 service_settings = service.get_service_settings()
492 connection_info = service_settings.get_connection_info()
493 retry_writes = config.getini('mongo-retry-writes')
494 return dataclasses.replace(connection_info, retry_writes=retry_writes)