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 'nofilldb: disable MongoDB fixture-data loading for marked test',
105 )
106 config.addinivalue_line(
107 'markers',
108 'noshuffledb: disable data set shuffle for marked test',
109 )
110 config.addinivalue_line(
111 'markers',
112 'filldb: specify mongo static file suffix',
113 )
114 config.addinivalue_line(
115 'markers',
116 'mongodb_collections: override mongo collections list',
117 )
118
119
120def pytest_addoption(parser):
121 """
122 :param parser: pytest's argument parser
123 """
124 group = parser.getgroup('mongo')
125 group.addoption('--mongo', help='Mongo connection string.')
126 group.addoption(
127 '--no-indexes',
128 action='store_true',
129 help='Disable index creation.',
130 )
131 group.addoption(
132 '--no-shuffle-db',
133 action='store_true',
134 help='Disable fixture data shuffle.',
135 )
136 group.addoption(
137 '--no-sharding',
138 action='store_true',
139 help='Disable collections sharding.',
140 )
141 group.addoption(
142 '--no-mongo',
143 help='Disable mongo startup',
144 action='store_true',
145 )
146 parser.addini(
147 'mongo-retry-writes',
148 type='bool',
149 default=False,
150 help=(
151 "Controls value of 'retryWrites' parameter of mongo connection "
152 'string.'
153 ),
154 )
155
156
157def pytest_report_header(config):
158 conninfo = _get_connection_info(config)
159 return [f'Mongo: {conninfo.get_uri()}']
160
161
162def pytest_service_register(register_service):
163 register_service('mongo', service.create_mongo_service)
164
165
166def pytest_register_object_hooks():
167 return {key: _mongo_object_hook for key in MONGO_OBJECT_HOOKS}
168
169
170@pytest.fixture
171def mongodb(
172 mongodb_init,
173 _mongodb_local: CollectionWrapper,
174) -> CollectionWrapper:
175 """Returns MongoDB collections wrapper for the test.
176
177 Collections are initialized by the @c mongodb_init fixture.
178
179 @ingroup userver_testsuite_fixtures
180 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/databases/mongo/pytest_plugin.py#L171)
181 """
182 return _mongodb_local
183
184
185@pytest.fixture
186def mongo_connections(
187 mongodb_settings,
188 mongo_connection_info,
189 mongo_extra_connections,
190 _mongo_local_collections,
191) -> dict[str, str]:
192 mongo_connection_uri = mongo_connection_info.get_uri()
193 return {
194 **{
195 mongodb_settings[name]['settings'][
196 'connection'
197 ]: mongo_connection_uri
198 for name in _mongo_local_collections
199 },
200 **{
201 extra_conn: mongo_connection_uri
202 for extra_conn in mongo_extra_connections
203 },
204 }
205
206
207@pytest.fixture
208def mongo_extra_connections() -> tuple[str, ...]:
209 """
210 Override this if you need to access mongo connections besides those
211 defined in mongo_connections fixture
212
213 @ingroup userver_testsuite_fixtures
214 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/databases/mongo/pytest_plugin.py#L205)
215 """
216 return ()
217
218
219@pytest.fixture(scope='session')
220def mongo_connection_info(
221 pytestconfig,
222) -> connection.ConnectionInfo:
223 return _get_connection_info(pytestconfig)
224
225
226@pytest.fixture
227def mongodb_settings(
228 mongo_schema_directory,
229 mongo_schema_extra_directories,
230 _mongo_schema_cache,
231) -> mongo_schema.MongoSchemas:
232 return mongo_schema.MongoSchemas(
233 _mongo_schema_cache,
234 (mongo_schema_directory, *mongo_schema_extra_directories),
235 )
236
237
238@pytest.fixture
239def mongodb_collections(mongodb_settings) -> tuple[str, ...]:
240 """
241 Override this to enable access to named collections within test module
242
243 Returns all available collections by default.
244
245 @ingroup userver_testsuite_fixtures
246 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/databases/mongo/pytest_plugin.py#L233)
247 """
248 return tuple(mongodb_settings.keys())
249
250
251@pytest.fixture(scope='session')
252def mongo_schema_extra_directories() -> tuple[str, ...]:
253 """
254 Override to use collection schemas besides those defined by
255 @c mongo_schema_directory fixture
256
257 @ingroup userver_testsuite_fixtures
258 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/databases/mongo/pytest_plugin.py#L243)
259 """
260 return ()
261
262
263@pytest.fixture(scope='session')
264def _mongo_indexes_ensured() -> set[str]:
265 return set()
266
267
268@pytest.fixture
269def _mongo_service(
270 pytestconfig,
271 ensure_service_started,
272 _mongodb_local,
273 _mongo_service_settings,
274) -> None:
275 aliases = _mongodb_local.get_aliases()
276 if (
277 aliases
278 and not pytestconfig.option.mongo
279 and not pytestconfig.option.no_mongo
280 ):
281 ensure_service_started('mongo', settings=_mongo_service_settings)
282
283
284@pytest.fixture
285def _mongo_create_indexes(
286 _mongodb_local,
287 mongodb_settings,
288 pytestconfig,
289 _mongo_indexes_ensured,
290 _mongo_service,
291) -> None:
292 aliases = _mongodb_local.get_aliases()
293 if not pytestconfig.option.no_indexes:
294 _ensure_indexes = {}
295 for alias in aliases:
296 if (
297 alias not in _mongo_indexes_ensured
298 and alias in mongodb_settings
299 ):
300 _ensure_indexes[alias] = mongodb_settings[alias]
301 if _ensure_indexes:
302 sharding_enabled = not pytestconfig.option.no_sharding
303 ensure_db_indexes.ensure_db_indexes(
304 _mongodb_local,
305 _ensure_indexes,
306 sharding_enabled=sharding_enabled,
307 )
308 _mongo_indexes_ensured.update(_ensure_indexes)
309
310
311@pytest.fixture(scope='session')
312def _mongo_thread_pool() -> types.YieldFixture[
313 multiprocessing.pool.ThreadPool,
314]:
315 pool = multiprocessing.pool.ThreadPool(processes=1)
316 with contextlib.closing(pool):
317 yield pool
318
319
320@pytest.fixture
321def _mongo_query_loader(load_json):
322 def loader(filename, missing_ok=False):
323 data = load_json(filename, missing_ok=missing_ok)
324 if data is None:
325 return []
326 return data
327
328 return loader
329
330
331@pytest.fixture
332def mongodb_init(
333 request,
334 verify_file_paths,
335 static_dir: pathlib.Path,
336 _mongodb_local,
337 _mongo_thread_pool,
338 _mongo_create_indexes,
339 _mongo_query_loader,
340) -> None:
341 """Populate mongodb with fixture data.
342
343 @ingroup userver_testsuite_fixtures
344 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/databases/mongo/pytest_plugin.py#L320)
345 """
346
347 if request.node.get_closest_marker('nofilldb'):
348 return
349
350 # Disable shuffle to make some buggy test work
351 shuffle_enabled = (
352 not request.config.option.no_shuffle_db
353 and not request.node.get_closest_marker('noshuffledb')
354 )
355 aliases = {key: key for key in _mongodb_local.get_aliases()}
356 requested = set()
357
358 for marker in request.node.iter_markers('filldb'):
359 for dbname, alias in marker.kwargs.items():
360 if dbname not in aliases:
361 raise UnknownCollectionError(
362 f'Unknown collection {dbname} requested'
363 )
364 if alias != 'default':
365 aliases[dbname] = '{}_{}'.format(dbname, alias)
366 requested.add(dbname)
367
368 def _verify_db_alias(file_path: pathlib.Path) -> bool:
369 if not _is_relevant_file(request, static_dir, file_path):
370 return True
371 match = DB_FILE_RE_PATTERN.search(file_path.name)
372 if match:
373 db_alias = match.group('mongo_db_alias')
374 if db_alias not in aliases and not any(
375 db_alias.startswith(alias + '_') for alias in aliases
376 ):
377 return False
378 return True
379
380 verify_file_paths(
381 _verify_db_alias,
382 check_name='mongo_db_aliases',
383 text_at_fail='file has not valid mongo collection name alias '
384 '(probably should add to service.yaml)',
385 )
386
387 def load_collection(params):
388 dbname, alias = params
389 try:
390 col = getattr(_mongodb_local, dbname)
391 except AttributeError:
392 return
393
394 docs = _mongo_query_loader(
395 f'db_{alias}.json', missing_ok=dbname not in requested
396 )
397
398 if not docs and col.find_one({}, []) is None:
399 return
400
401 if shuffle_enabled:
402 # Make sure there is no tests that depend on order of
403 # documents in fixture file.
404 random.shuffle(docs)
405
406 try:
407 col.bulk_write(
408 [
409 pymongo.DeleteMany({}),
410 *(pymongo.InsertOne(doc) for doc in docs),
411 ],
412 ordered=True,
413 )
414 except pymongo.errors.BulkWriteError as bwe:
415 pprint.pprint(bwe.details)
416 raise
417
418 pool_args = []
419 for dbname, alias in aliases.items():
420 pool_args.append((dbname, alias))
421
422 _mongo_thread_pool.map(load_collection, pool_args)
423
424
425@pytest.fixture
426def _mongodb_local(
427 mongodb_settings,
428 _mongo_local_collections,
429 _mongo_collection_wrapper_factory: CollectionWrapperFactory,
430) -> CollectionWrapper:
431 return _mongo_collection_wrapper_factory.create_collection_wrapper(
432 _mongo_local_collections,
433 mongodb_settings,
434 )
435
436
437@pytest.fixture(scope='session')
438def _mongo_collection_wrapper_factory(
439 mongo_connection_info: connection.ConnectionInfo,
440) -> CollectionWrapperFactory:
441 return CollectionWrapperFactory(mongo_connection_info)
442
443
444@pytest.fixture
445def _mongo_local_collections(request, mongodb_collections) -> set[str]:
446 result = set(mongodb_collections)
447 for marker in request.node.iter_markers('mongodb_collections'):
448 result.update(marker.args)
449 return result
450
451
452@pytest.fixture(scope='session')
453def _mongo_schema_cache() -> mongo_schema.MongoSchemaCache:
454 return mongo_schema.MongoSchemaCache()
455
456
457@pytest.fixture(scope='session')
458def _mongo_service_settings(
459 pytestconfig,
460) -> service.ServiceSettings | None:
461 if pytestconfig.option.mongo:
462 return None
463 return service.get_service_settings()
464
465
466def _is_relevant_file(
467 request,
468 static_dir: pathlib.Path,
469 file_path: pathlib.Path,
470) -> bool:
471 default_static_dir = static_dir / 'default'
472 module_static_dir = static_dir / pathlib.Path(request.fspath).stem
473 return _is_nested_path(file_path, default_static_dir) or _is_nested_path(
474 file_path,
475 module_static_dir,
476 )
477
478
479def _is_nested_path(parent: pathlib.Path, nested: pathlib.Path) -> bool:
480 try:
481 pathlib.PurePath(nested).relative_to(parent)
482 return True
483 except ValueError:
484 return False
485
486
487def _mongo_object_hook(doc):
488 return json_util.object_hook(doc, JSON_OPTIONS)
489
490
491def _get_connection_info(config):
492 # External mongo instance
493 if config.option.mongo:
494 return connection.parse_connection_uri(config.option.mongo)
495 service_settings = service.get_service_settings()
496 connection_info = service_settings.get_connection_info()
497 retry_writes = config.getini('mongo-retry-writes')
498 return dataclasses.replace(connection_info, retry_writes=retry_writes)