codekingpro/portable-devtools
114k
1# testing/plugin/plugin_base.py
2# Copyright (C) 2005-2024 the SQLAlchemy authors and contributors
3# <see AUTHORS file>
4#
5# This module is part of SQLAlchemy and is released under
6# the MIT License: https://www.opensource.org/licenses/mit-license.php
7# mypy: ignore-errors
8
9
10from __future__ import annotations
11
12import abc
13from argparse import Namespace
14import configparser
15import logging
16import os
17from pathlib import Path
18import re
19import sys
20from typing import Any
21
22from sqlalchemy.testing import asyncio
23
24"""Testing extensions.
25
26this module is designed to work as a testing-framework-agnostic library,
27created so that multiple test frameworks can be supported at once
28(mostly so that we can migrate to new ones). The current target
29is pytest.
30
31"""
32
33# flag which indicates we are in the SQLAlchemy testing suite,
34# and not that of Alembic or a third party dialect.
35bootstrapped_as_sqlalchemy = False
36
37log = logging.getLogger("sqlalchemy.testing.plugin_base")
38
39# late imports
40fixtures = None
41engines = None
42exclusions = None
43warnings = None
44profiling = None
45provision = None
46assertions = None
47requirements = None
48config = None
49testing = None
50util = None
51file_config = None
52
53logging = None
54include_tags = set()
55exclude_tags = set()
56options: Namespace = None # type: ignore
57
58
59def setup_options(make_option):
60 make_option(
61 "--log-info",
62 action="callback",
63 type=str,
64 callback=_log,
65 help="turn on info logging for <LOG> (multiple OK)",
66 )
67 make_option(
68 "--log-debug",
69 action="callback",
70 type=str,
71 callback=_log,
72 help="turn on debug logging for <LOG> (multiple OK)",
73 )
74 make_option(
75 "--db",
76 action="append",
77 type=str,
78 dest="db",
79 help="Use prefab database uri. Multiple OK, "
80 "first one is run by default.",
81 )
82 make_option(
83 "--dbs",
84 action="callback",
85 zeroarg_callback=_list_dbs,
86 help="List available prefab dbs",
87 )
88 make_option(
89 "--dburi",
90 action="append",
91 type=str,
92 dest="dburi",
93 help="Database uri. Multiple OK, first one is run by default.",
94 )
95 make_option(
96 "--dbdriver",
97 action="append",
98 type=str,
99 dest="dbdriver",
100 help="Additional database drivers to include in tests. "
101 "These are linked to the existing database URLs by the "
102 "provisioning system.",
103 )
104 make_option(
105 "--dropfirst",
106 action="store_true",
107 dest="dropfirst",
108 help="Drop all tables in the target database first",
109 )
110 make_option(
111 "--disable-asyncio",
112 action="store_true",
113 help="disable test / fixtures / provisoning running in asyncio",
114 )
115 make_option(
116 "--backend-only",
117 action="callback",
118 zeroarg_callback=_set_tag_include("backend"),
119 help=(
120 "Run only tests marked with __backend__ or __sparse_backend__; "
121 "this is now equivalent to the pytest -m backend mark expression"
122 ),
123 )
124 make_option(
125 "--nomemory",
126 action="callback",
127 zeroarg_callback=_set_tag_exclude("memory_intensive"),
128 help="Don't run memory profiling tests; "
129 "this is now equivalent to the pytest -m 'not memory_intensive' "
130 "mark expression",
131 )
132 make_option(
133 "--notimingintensive",
134 action="callback",
135 zeroarg_callback=_set_tag_exclude("timing_intensive"),
136 help="Don't run timing intensive tests; "
137 "this is now equivalent to the pytest -m 'not timing_intensive' "
138 "mark expression",
139 )
140 make_option(
141 "--nomypy",
142 action="callback",
143 zeroarg_callback=_set_tag_exclude("mypy"),
144 help="Don't run mypy typing tests; "
145 "this is now equivalent to the pytest -m 'not mypy' mark expression",
146 )
147 make_option(
148 "--profile-sort",
149 type=str,
150 default="cumulative",
151 dest="profilesort",
152 help="Type of sort for profiling standard output",
153 )
154 make_option(
155 "--profile-dump",
156 type=str,
157 dest="profiledump",
158 help="Filename where a single profile run will be dumped",
159 )
160 make_option(
161 "--low-connections",
162 action="store_true",
163 dest="low_connections",
164 help="Use a low number of distinct connections - "
165 "i.e. for Oracle TNS",
166 )
167 make_option(
168 "--write-idents",
169 type=str,
170 dest="write_idents",
171 help="write out generated follower idents to <file>, "
172 "when -n<num> is used",
173 )
174 make_option(
175 "--requirements",
176 action="callback",
177 type=str,
178 callback=_requirements_opt,
179 help="requirements class for testing, overrides setup.cfg",
180 )
181 make_option(
182 "--include-tag",
183 action="callback",
184 callback=_include_tag,
185 type=str,
186 help="Include tests with tag <tag>; "
187 "legacy, use pytest -m 'tag' instead",
188 )
189 make_option(
190 "--exclude-tag",
191 action="callback",
192 callback=_exclude_tag,
193 type=str,
194 help="Exclude tests with tag <tag>; "
195 "legacy, use pytest -m 'not tag' instead",
196 )
197 make_option(
198 "--write-profiles",
199 action="store_true",
200 dest="write_profiles",
201 default=False,
202 help="Write/update failing profiling data.",
203 )
204 make_option(
205 "--force-write-profiles",
206 action="store_true",
207 dest="force_write_profiles",
208 default=False,
209 help="Unconditionally write/update profiling data.",
210 )
211 make_option(
212 "--dump-pyannotate",
213 type=str,
214 dest="dump_pyannotate",
215 help="Run pyannotate and dump json info to given file",
216 )
217 make_option(
218 "--mypy-extra-test-path",
219 type=str,
220 action="append",
221 default=[],
222 dest="mypy_extra_test_paths",
223 help="Additional test directories to add to the mypy tests. "
224 "This is used only when running mypy tests. Multiple OK",
225 )
226 # db specific options
227 make_option(
228 "--postgresql-templatedb",
229 type=str,
230 help="name of template database to use for PostgreSQL "
231 "CREATE DATABASE (defaults to current database)",
232 )
233 make_option(
234 "--oracledb-thick-mode",
235 action="store_true",
236 help="enables the 'thick mode' when testing with oracle+oracledb",
237 )
238
239
240def configure_follower(follower_ident):
241 """Configure required state for a follower.
242
243 This invokes in the parent process and typically includes
244 database creation.
245
246 """
247 from sqlalchemy.testing import provision
248
249 provision.FOLLOWER_IDENT = follower_ident
250
251
252def memoize_important_follower_config(dict_):
253 """Store important configuration we will need to send to a follower.
254
255 This invokes in the parent process after normal config is set up.
256
257 Hook is currently not used.
258
259 """
260
261
262def restore_important_follower_config(dict_):
263 """Restore important configuration needed by a follower.
264
265 This invokes in the follower process.
266
267 Hook is currently not used.
268
269 """
270
271
272def read_config(root_path):
273 global file_config
274 file_config = configparser.ConfigParser()
275 file_config.read(
276 [str(root_path / "setup.cfg"), str(root_path / "test.cfg")]
277 )
278
279
280def pre_begin(opt):
281 """things to set up early, before coverage might be setup."""
282 global options
283 options = opt
284 for fn in pre_configure:
285 fn(options, file_config)
286
287
288def set_coverage_flag(value):
289 options.has_coverage = value
290
291
292def post_begin():
293 """things to set up later, once we know coverage is running."""
294 # Lazy setup of other options (post coverage)
295 for fn in post_configure:
296 fn(options, file_config)
297
298 # late imports, has to happen after config.
299 global util, fixtures, engines, exclusions, assertions, provision
300 global warnings, profiling, config, testing
301 from sqlalchemy import testing # noqa
302 from sqlalchemy.testing import fixtures, engines, exclusions # noqa
303 from sqlalchemy.testing import assertions, warnings, profiling # noqa
304 from sqlalchemy.testing import config, provision # noqa
305 from sqlalchemy import util # noqa
306
307 warnings.setup_filters()
308
309
310def _log(opt_str, value, parser):
311 global logging
312 if not logging:
313 import logging
314
315 logging.basicConfig()
316
317 if opt_str.endswith("-info"):
318 logging.getLogger(value).setLevel(logging.INFO)
319 elif opt_str.endswith("-debug"):
320 logging.getLogger(value).setLevel(logging.DEBUG)
321
322
323def _list_dbs(*args):
324 if file_config is None:
325 # assume the current working directory is the one containing the
326 # setup file
327 read_config(Path.cwd())
328 print("Available --db options (use --dburi to override)")
329 for macro in sorted(file_config.options("db")):
330 print("%20s\t%s" % (macro, file_config.get("db", macro)))
331 sys.exit(0)
332
333
334def _requirements_opt(opt_str, value, parser):
335 _setup_requirements(value)
336
337
338def _set_tag_include(tag):
339 def _do_include_tag(opt_str, value, parser):
340 _include_tag(opt_str, tag, parser)
341
342 return _do_include_tag
343
344
345def _set_tag_exclude(tag):
346 def _do_exclude_tag(opt_str, value, parser):
347 _exclude_tag(opt_str, tag, parser)
348
349 return _do_exclude_tag
350
351
352def _exclude_tag(opt_str, value, parser):
353 exclude_tags.add(value.replace("-", "_"))
354
355
356def _include_tag(opt_str, value, parser):
357 include_tags.add(value.replace("-", "_"))
358
359
360pre_configure = []
361post_configure = []
362
363
364def pre(fn):
365 pre_configure.append(fn)
366 return fn
367
368
369def post(fn):
370 post_configure.append(fn)
371 return fn
372
373
374@pre
375def _setup_options(opt, file_config):
376 global options
377 options = opt
378
379
380@pre
381def _register_sqlite_numeric_dialect(opt, file_config):
382 from sqlalchemy.dialects import registry
383
384 registry.register(
385 "sqlite.pysqlite_numeric",
386 "sqlalchemy.dialects.sqlite.pysqlite",
387 "_SQLiteDialect_pysqlite_numeric",
388 )
389 registry.register(
390 "sqlite.pysqlite_dollar",
391 "sqlalchemy.dialects.sqlite.pysqlite",
392 "_SQLiteDialect_pysqlite_dollar",
393 )
394
395
396@post
397def __ensure_cext(opt, file_config):
398 if os.environ.get("REQUIRE_SQLALCHEMY_CEXT", "0") == "1":
399 from sqlalchemy.util import has_compiled_ext
400
401 try:
402 has_compiled_ext(raise_=True)
403 except ImportError as err:
404 raise AssertionError(
405 "REQUIRE_SQLALCHEMY_CEXT is set but can't import the "
406 "cython extensions"
407 ) from err
408
409
410@post
411def _init_symbols(options, file_config):
412 from sqlalchemy.testing import config
413
414 config._fixture_functions = _fixture_fn_class()
415
416
417@pre
418def _set_disable_asyncio(opt, file_config):
419 if opt.disable_asyncio:
420 asyncio.ENABLE_ASYNCIO = False
421
422
423@post
424def _engine_uri(options, file_config):
425 from sqlalchemy import testing
426 from sqlalchemy.testing import config
427 from sqlalchemy.testing import provision
428 from sqlalchemy.engine import url as sa_url
429
430 if options.dburi:
431 db_urls = list(options.dburi)
432 else:
433 db_urls = []
434
435 extra_drivers = options.dbdriver or []
436
437 if options.db:
438 for db_token in options.db:
439 for db in re.split(r"[,\s]+", db_token):
440 if db not in file_config.options("db"):
441 raise RuntimeError(
442 "Unknown URI specifier '%s'. "
443 "Specify --dbs for known uris." % db
444 )
445 else:
446 db_urls.append(file_config.get("db", db))
447
448 if not db_urls:
449 db_urls.append(file_config.get("db", "default"))
450
451 config._current = None
452
453 if options.write_idents and provision.FOLLOWER_IDENT:
454 for db_url in [sa_url.make_url(db_url) for db_url in db_urls]:
455 with open(options.write_idents, "a") as file_:
456 file_.write(
457 f"{provision.FOLLOWER_IDENT} "
458 f"{db_url.render_as_string(hide_password=False)}\n"
459 )
460
461 expanded_urls = list(provision.generate_db_urls(db_urls, extra_drivers))
462
463 for db_url in expanded_urls:
464 log.info("Adding database URL: %s", db_url)
465
466 cfg = provision.setup_config(
467 db_url, options, file_config, provision.FOLLOWER_IDENT
468 )
469 if not config._current:
470 cfg.set_as_current(cfg, testing)
471
472
473@post
474def _requirements(options, file_config):
475 requirement_cls = file_config.get("sqla_testing", "requirement_cls")
476 _setup_requirements(requirement_cls)
477
478
479def _setup_requirements(argument):
480 from sqlalchemy.testing import config
481 from sqlalchemy import testing
482
483 modname, clsname = argument.split(":")
484
485 # importlib.import_module() only introduced in 2.7, a little
486 # late
487 mod = __import__(modname)
488 for component in modname.split(".")[1:]:
489 mod = getattr(mod, component)
490 req_cls = getattr(mod, clsname)
491
492 config.requirements = testing.requires = req_cls()
493
494 config.bootstrapped_as_sqlalchemy = bootstrapped_as_sqlalchemy
495
496
497@post
498def _prep_testing_database(options, file_config):
499 from sqlalchemy.testing import config
500
501 if options.dropfirst:
502 from sqlalchemy.testing import provision
503
504 for cfg in config.Config.all_configs():
505 provision.drop_all_schema_objects(cfg, cfg.db)
506
507
508@post
509def _post_setup_options(opt, file_config):
510 from sqlalchemy.testing import config
511
512 config.options = options
513 config.file_config = file_config
514
515
516@post
517def _setup_profiling(options, file_config):
518 from sqlalchemy.testing import profiling
519
520 profiling._profile_stats = profiling.ProfileStatsFile(
521 file_config.get("sqla_testing", "profile_file"),
522 sort=options.profilesort,
523 dump=options.profiledump,
524 )
525
526
527def want_class(name, cls):
528 if not issubclass(cls, fixtures.TestBase):
529 return False
530 elif name.startswith("_"):
531 return False
532 else:
533 return True
534
535
536def want_method(cls, fn):
537 if not fn.__name__.startswith("test_"):
538 return False
539 elif fn.__module__ is None:
540 return False
541 else:
542 return True
543
544
545def generate_sub_tests(cls, module, markers):
546 if "backend" in markers or "sparse_backend" in markers:
547 sparse = "sparse_backend" in markers
548 for cfg in _possible_configs_for_cls(cls, sparse=sparse):
549 orig_name = cls.__name__
550
551 # we can have special chars in these names except for the
552 # pytest junit plugin, which is tripped up by the brackets
553 # and periods, so sanitize
554
555 alpha_name = re.sub(r"[_\[\]\.]+", "_", cfg.name)
556 alpha_name = re.sub(r"_+$", "", alpha_name)
557 name = "%s_%s" % (cls.__name__, alpha_name)
558 subcls = type(
559 name,
560 (cls,),
561 {"_sa_orig_cls_name": orig_name, "__only_on_config__": cfg},
562 )
563 setattr(module, name, subcls)
564 yield subcls
565 else:
566 yield cls
567
568
569def start_test_class_outside_fixtures(cls):
570 _do_skips(cls)
571 _setup_engine(cls)
572
573
574def stop_test_class(cls):
575 # close sessions, immediate connections, etc.
576 fixtures.stop_test_class_inside_fixtures(cls)
577
578 # close outstanding connection pool connections, dispose of
579 # additional engines
580 engines.testing_reaper.stop_test_class_inside_fixtures()
581
582
583def stop_test_class_outside_fixtures(cls):
584 engines.testing_reaper.stop_test_class_outside_fixtures()
585 provision.stop_test_class_outside_fixtures(config, config.db, cls)
586 try:
587 if not options.low_connections:
588 assertions.global_cleanup_assertions()
589 finally:
590 _restore_engine()
591
592
593def _restore_engine():
594 if config._current:
595 config._current.reset(testing)
596
597
598def final_process_cleanup():
599 engines.testing_reaper.final_cleanup()
600 assertions.global_cleanup_assertions()
601 _restore_engine()
602
603
604def _setup_engine(cls):
605 if getattr(cls, "__engine_options__", None):
606 opts = dict(cls.__engine_options__)
607 opts["scope"] = "class"
608 eng = engines.testing_engine(options=opts)
609 config._current.push_engine(eng, testing)
610
611
612def before_test(test, test_module_name, test_class, test_name):
613 # format looks like:
614 # "test.aaa_profiling.test_compiler.CompileTest.test_update_whereclause"
615
616 name = getattr(test_class, "_sa_orig_cls_name", test_class.__name__)
617
618 id_ = "%s.%s.%s" % (test_module_name, name, test_name)
619
620 profiling._start_current_test(id_)
621
622
623def after_test(test):
624 fixtures.after_test()
625 engines.testing_reaper.after_test()
626
627
628def after_test_fixtures(test):
629 engines.testing_reaper.after_test_outside_fixtures(test)
630
631
632def _possible_configs_for_cls(cls, reasons=None, sparse=False):
633 all_configs = set(config.Config.all_configs())
634
635 if cls.__unsupported_on__:
636 spec = exclusions.db_spec(*cls.__unsupported_on__)
637 for config_obj in list(all_configs):
638 if spec(config_obj):
639 all_configs.remove(config_obj)
640
641 if getattr(cls, "__only_on__", None):
642 spec = exclusions.db_spec(*util.to_list(cls.__only_on__))
643 for config_obj in list(all_configs):
644 if not spec(config_obj):
645 all_configs.remove(config_obj)
646
647 if getattr(cls, "__only_on_config__", None):
648 all_configs.intersection_update([cls.__only_on_config__])
649
650 if hasattr(cls, "__requires__"):
651 requirements = config.requirements
652 for config_obj in list(all_configs):
653 for requirement in cls.__requires__:
654 check = getattr(requirements, requirement)
655
656 skip_reasons = check.matching_config_reasons(config_obj)
657 if skip_reasons:
658 all_configs.remove(config_obj)
659 if reasons is not None:
660 reasons.extend(skip_reasons)
661 break
662
663 if hasattr(cls, "__prefer_requires__"):
664 non_preferred = set()
665 requirements = config.requirements
666 for config_obj in list(all_configs):
667 for requirement in cls.__prefer_requires__:
668 check = getattr(requirements, requirement)
669
670 if not check.enabled_for_config(config_obj):
671 non_preferred.add(config_obj)
672 if all_configs.difference(non_preferred):
673 all_configs.difference_update(non_preferred)
674
675 if sparse:
676 # pick only one config from each base dialect
677 # sorted so we get the same backend each time selecting the highest
678 # server version info.
679 per_dialect = {}
680 for cfg in reversed(
681 sorted(
682 all_configs,
683 key=lambda cfg: (
684 cfg.db.name,
685 cfg.db.driver,
686 cfg.db.dialect.server_version_info,
687 ),
688 )
689 ):
690 db = cfg.db.name
691 if db not in per_dialect:
692 per_dialect[db] = cfg
693 return per_dialect.values()
694
695 return all_configs
696
697
698def _do_skips(cls):
699 reasons = []
700 all_configs = _possible_configs_for_cls(cls, reasons)
701
702 if getattr(cls, "__skip_if__", False):
703 for c in getattr(cls, "__skip_if__"):
704 if c():
705 config.skip_test(
706 "'%s' skipped by %s" % (cls.__name__, c.__name__)
707 )
708
709 if not all_configs:
710 msg = "'%s.%s' unsupported on any DB implementation %s%s" % (
711 cls.__module__,
712 cls.__name__,
713 ", ".join(
714 "'%s(%s)+%s'"
715 % (
716 config_obj.db.name,
717 ".".join(
718 str(dig)
719 for dig in exclusions._server_version(config_obj.db)
720 ),
721 config_obj.db.driver,
722 )
723 for config_obj in config.Config.all_configs()
724 ),
725 ", ".join(reasons),
726 )
727 config.skip_test(msg)
728 elif hasattr(cls, "__prefer_backends__"):
729 non_preferred = set()
730 spec = exclusions.db_spec(*util.to_list(cls.__prefer_backends__))
731 for config_obj in all_configs:
732 if not spec(config_obj):
733 non_preferred.add(config_obj)
734 if all_configs.difference(non_preferred):
735 all_configs.difference_update(non_preferred)
736
737 if config._current not in all_configs:
738 _setup_config(all_configs.pop(), cls)
739
740
741def _setup_config(config_obj, ctx):
742 config._current.push(config_obj, testing)
743
744
745class FixtureFunctions(abc.ABC):
746 @abc.abstractmethod
747 def skip_test_exception(self, *arg, **kw):
748 raise NotImplementedError()
749
750 @abc.abstractmethod
751 def combinations(self, *args, **kw):
752 raise NotImplementedError()
753
754 @abc.abstractmethod
755 def param_ident(self, *args, **kw):
756 raise NotImplementedError()
757
758 @abc.abstractmethod
759 def fixture(self, *arg, **kw):
760 raise NotImplementedError()
761
762 def get_current_test_name(self):
763 raise NotImplementedError()
764
765 @abc.abstractmethod
766 def mark_base_test_class(self) -> Any:
767 raise NotImplementedError()
768
769 @abc.abstractproperty
770 def add_to_marker(self):
771 raise NotImplementedError()
772
773
774_fixture_fn_class = None
775
776
777def set_fixture_functions(fixture_fn_class):
778 global _fixture_fn_class
779 _fixture_fn_class = fixture_fn_class
780 