diffcone 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2680 @@
1
+ """Static pytest discovery.
2
+
3
+ Reproduces the collection rules below without importing test code. Anything
4
+ outside them is reported, never guessed.
5
+
6
+ Collected:
7
+
8
+ * files matching ``python_files`` (default ``test_*.py``, ``*_test.py``) under
9
+ the source roots, restricted to ``testpaths`` when configured;
10
+ * module-level functions matching ``python_functions`` (default prefix
11
+ ``test``), methods of classes matching ``python_classes`` (default prefix
12
+ ``Test``) that have no ``__init__`` (a TestCase is collected with one),
13
+ nested test classes (those a class inherits included), methods
14
+ inherited from base classes defined in the same module or imported from
15
+ one in the source roots (an ``alias.Class`` base resolves through that
16
+ alias's module; a class a module only imports, as pandas's
17
+ ``tests/extension/base/__init__.py`` re-exports its submodules' classes,
18
+ is followed to the module that defines it), and methods of classes that reach a base ending in
19
+ ``TestCase`` anywhere in that chain, whatever the class is called;
20
+ * functions and classes imported into a test module (``from docs_src.app
21
+ import test_read_main``) that match the naming rules, named by the bound
22
+ name, with the defining symbol as entry; an imported class is collected
23
+ as a class defined here (inheritance, class fixtures, marks, xunit
24
+ setup, nested classes), its bases resolved in the module that defines
25
+ it, followed through re-exports to the module defining it; ``from
26
+ <module> import *`` brings in what the module's ``__all__`` lists, or
27
+ every public name it binds, imports included (poetry's sync tests are the
28
+ install tests, star-imported), or both and every string named when
29
+ ``__all__`` is computed, except names this module defines itself;
30
+ a name imported from outside the source roots is reported
31
+ (``imported_test_out_of_scope``), since whether it yields tests is
32
+ unknown, except unittest's names and a test framework's ``*TestCase``
33
+ (``from django.test import TestCase``), which yield none; a class
34
+ that defines test methods but does not match ``python_classes`` is
35
+ reported too (``uncollected_test_class``): a plugin may collect it, as
36
+ SQLAlchemy's testing plugin collects ``<Name>Test``;
37
+ * tests bound by assignment (``test_alias = test_orig``, a class attribute
38
+ ``test_x = _check``) to a function or class defined there; one bound to
39
+ something not visible there (``TestMachine = Machine.TestCase``) is
40
+ reported (``unmodelled_test_binding``); a function marked
41
+ ``f.__test__ = True``; a unittest ``runTest`` when a TestCase has no
42
+ ``test*`` method; an imported TestCase whatever its bound name;
43
+ * ``testpaths`` as pytest applies it: entries that exist (none existing
44
+ falls back to the rootdir), a file named there collected whatever
45
+ ``python_files`` says; a test file pytest would collect outside the source
46
+ roots is reported (``test_file_outside_roots``);
47
+ * configuration from ``pytest.toml``, ``.pytest.toml``, ``pytest.ini``,
48
+ ``.pytest.ini``, ``pyproject.toml`` (``[tool.pytest.ini_options]`` or
49
+ ``[tool.pytest]``), ``tox.ini`` or ``setup.cfg`` at the repository root,
50
+ in pytest 9's order; INI values split as a shell would; an ``addopts``
51
+ override of the configuration (``-o``, ``-c``, ``--rootdir``,
52
+ ``--pyargs``) or path is reported (``unmodelled_runner_option``);
53
+ * a ``conftest.py`` outside the source roots is reported
54
+ (``conftest_outside_roots``) and is an unknown dependency
55
+ (``conftest:<path>``) of every test under it, so those tests are always
56
+ selected.
57
+
58
+ Lifecycle dependencies attached to each test:
59
+
60
+ * fixtures requested by parameter name (excluding parameters with defaults,
61
+ ``parametrize`` argnames unless ``indirect``, and arguments injected by
62
+ ``mock.patch`` decorators on the function or, for ``test*`` methods, on
63
+ its class and base classes), by ``@pytest.mark.usefixtures`` on the
64
+ function, class (including enclosing classes and every base class, as
65
+ pytest reads marks along the MRO) or module (``pytestmark``), whether
66
+ written out or stored in a variable (``skip_pyarrow =
67
+ pytest.mark.usefixtures("pyarrow_skip")`` then ``@skip_pyarrow``, also
68
+ through aliases, ``from`` imports and ``module.name``, resolved in the
69
+ module that defines the decorated function or class),
70
+ and transitively by other fixtures, resolved in pytest's order: class
71
+ (its own fixtures and those it inherits from bases in any module in the
72
+ source roots, the nearest definition of an attribute hiding the rest, as
73
+ ``dir(cls)`` sees them), enclosing classes, module, nearest
74
+ ``conftest.py`` outward, then
75
+ ``pytest_plugins`` modules within the source roots (and the plugins those
76
+ declare, as pytest registers them); a fixture requesting
77
+ its own name resolves to the next definition outward; a module offers a
78
+ fixture under every name it binds the fixture to (``box2 = box``, ``from
79
+ pkg.conftest import engine as motor``, a star import), as pytest registers
80
+ it, except that a fixture with an explicit ``name=`` is offered under that
81
+ name only; a name the test's marks parametrize directly is a parameter at
82
+ every depth of the closure, not only among the test's own arguments
83
+ (``set_engine(engine, ext)`` under ``parametrize("engine, ext")``), as
84
+ pytest replaces any fixture of that name and prunes what it requests;
85
+ * fixtures requested by literal name through ``request.getfixturevalue``,
86
+ and every fixture visible from the test when the name is not a literal;
87
+ * the ini option ``usefixtures``, requested by every test;
88
+ * ``autouse`` fixtures visible from the test (``autouse=`` anything but a
89
+ literal false value, since ``autouse=FLAG`` applies whenever the flag is
90
+ set);
91
+ * what pytest's own fixtures request (``BUILTIN_REQUESTS``: ``tmp_path``
92
+ requests ``tmp_path_factory``), so an in-scope override of the requested
93
+ name is a dependency of the tests using the builtin;
94
+ * in-scope overrides of fixtures that installed plugins request themselves
95
+ (``PLUGIN_REQUESTED``: ``anyio_backend``, which anyio's plugin adds to
96
+ async tests, ``django_db_setup``, ``event_loop_policy``) visible from the
97
+ test, as if autouse, since the plugins cannot be read;
98
+ * the test module and its ``pytest_*`` hooks, every ``conftest.py`` on the
99
+ path and each ``pytest_*`` hook function in those conftests; the hooks
100
+ pytest calls for the whole session (any not in ``PATH_SCOPED_HOOKS``:
101
+ ``pytest_collection_modifyitems``, ``pytest_configure``, ...) of every
102
+ conftest, wherever it is; the hooks of the plugin modules the session
103
+ loads (``pytest_plugins`` in a conftest or a test module, ``-p`` in
104
+ addopts, ``pytest11`` entry points of the project or of a sibling package
105
+ in the repository); a hook or setup function bound by import or
106
+ assignment counts as one defined there;
107
+ * xunit-style setup/teardown functions and methods when present
108
+ (``setUpModule``, ``asyncSetUp``, Django's ``setUpTestData`` and a
109
+ class-level ``pytest_generate_tests`` included); a ``@staticmethod``
110
+ test keeps its first parameter as a fixture request;
111
+ * for inherited tests, the collecting class itself. Bases that resolve
112
+ nowhere are reported; a ``*TestCase`` base is excused only when it comes
113
+ from a test framework (``TESTCASE_FRAMEWORKS``).
114
+
115
+ Doctests, as pytest collects them: with ``--doctest-modules`` in
116
+ ``addopts``, every docstring with examples in a collected module (the
117
+ module's, its functions', classes', and their methods' and nested classes'),
118
+ named ``path::module.Qualified.name`` with the owning symbol as entry and a
119
+ ``dynamic:<module>`` lifecycle dependency (examples run with the module's
120
+ globals; plus one per in-scope module an example imports), the session-wide
121
+ hooks, and the fixtures pytest gives a doctest: the module's and its
122
+ conftests' autouse fixtures, the ini ``usefixtures`` and
123
+ ``doctest_namespace`` (which an autouse fixture usually fills); a module
124
+ outside the source roots is reported (``test_file_outside_roots``); text files
125
+ matching ``--doctest-glob`` (default ``test*.txt``) become targets whose
126
+ entry is not a symbol, so they are always selected.
127
+
128
+ Fixtures are recognised by a decorator whose dotted name ends in ``fixture``
129
+ (``@pytest.fixture``, ``@pytest.fixture(name=...)``, ``@fixture``,
130
+ ``@pytest_asyncio.fixture``), or a call of one binding a module function
131
+ (``x = pytest.fixture(scope=...)(f)``, ``x = pytest.fixture(f)``).
132
+ Overriding follows nearest-scope-wins. Fixture parametrisation and
133
+ ``indirect`` parametrisation are not modelled. Fixtures from installed
134
+ plugins cannot be seen: a name found at no in-scope level that a well-known
135
+ plugin provides (``WELL_KNOWN_PLUGIN_FIXTURES``: ``mocker`` from
136
+ pytest-mock, ``httpx_mock`` from pytest-httpx, ...) or that was declared
137
+ external is assumed to come from that plugin and reported in an
138
+ ``external_fixture`` note with its request count; any other unknown fixture
139
+ becomes the lifecycle dependency ``fixture:<name>`` so the planner selects
140
+ its users.
141
+ """
142
+
143
+ from __future__ import annotations
144
+
145
+ import ast
146
+ import configparser
147
+ import doctest
148
+ import shlex
149
+ import tomllib
150
+ from collections import Counter
151
+ from collections.abc import Callable, Iterable
152
+ from dataclasses import dataclass, field, replace
153
+ from fnmatch import fnmatch
154
+ from pathlib import PurePosixPath
155
+ from typing import Any
156
+
157
+ from diffcone.discovery import DiscoveryNote, DiscoveryOptions, DiscoveryResult
158
+ from diffcone.discovery.common import (
159
+ NO_GETFIXTUREVALUE,
160
+ ParsedModule,
161
+ decorator_chain,
162
+ keyword_value,
163
+ parse_modules,
164
+ scope_assignments,
165
+ scope_classes,
166
+ scope_functions,
167
+ string_literals,
168
+ )
169
+ from diffcone.indexer import DEF_NODES, iter_scope_statements, resolve_relative_module
170
+ from diffcone.manifest import Target
171
+ from diffcone.model import SourceIndex
172
+ from diffcone.snapshot import Snapshot, module_name_for
173
+
174
+ RUNNER = "pytest"
175
+
176
+ DEFAULT_PYTHON_FILES = ("test_*.py", "*_test.py")
177
+ DEFAULT_PYTHON_CLASSES = ("Test",)
178
+ DEFAULT_PYTHON_FUNCTIONS = ("test",)
179
+
180
+ # Fixtures provided by widely used pytest plugins, by distribution. A name
181
+ # defined at any in-scope level wins over this table; a project requesting
182
+ # one of these without the plugin installed fails at collection anyway, so
183
+ # assuming the plugin never hides a real dependency. Every use is reported.
184
+ _PLUGIN_FIXTURES: dict[str, tuple[str, ...]] = {
185
+ "pytest-mock": ("mocker", "class_mocker", "module_mocker", "package_mocker", "session_mocker"),
186
+ "pytest-subprocess": ("fp", "fake_process"),
187
+ "pytest-httpx": ("httpx_mock",),
188
+ "respx": ("respx_mock",),
189
+ "requests-mock": ("requests_mock",),
190
+ "pytest-responses": ("responses",),
191
+ "pytest-httpserver": (
192
+ "httpserver",
193
+ "make_httpserver",
194
+ "httpserver_listen_address",
195
+ "httpserver_ssl_context",
196
+ ),
197
+ "pytest-localserver": ("smtpserver",),
198
+ "pytest-httpbin": ("httpbin", "httpbin_secure", "httpbin_both", "httpbin_ca_bundle"),
199
+ "pytest-recording": ("vcr", "vcr_config", "vcr_cassette", "vcr_cassette_name"),
200
+ "pytest-freezer": ("freezer",),
201
+ "time-machine": ("time_machine",),
202
+ "pytest-asyncio": (
203
+ "event_loop",
204
+ "event_loop_policy",
205
+ "unused_tcp_port",
206
+ "unused_tcp_port_factory",
207
+ "unused_udp_port",
208
+ "unused_udp_port_factory",
209
+ ),
210
+ "anyio": (
211
+ "anyio_backend",
212
+ "anyio_backend_name",
213
+ "anyio_backend_options",
214
+ "free_tcp_port",
215
+ "free_tcp_port_factory",
216
+ "free_udp_port",
217
+ "free_udp_port_factory",
218
+ ),
219
+ "pytest-trio": ("nursery",),
220
+ "pytest-aiohttp": (
221
+ "aiohttp_client",
222
+ "aiohttp_server",
223
+ "aiohttp_raw_server",
224
+ "aiohttp_unused_port",
225
+ "aiohttp_client_cls",
226
+ ),
227
+ "pytest-tornasync": ("io_loop", "http_client", "http_server", "http_server_port"),
228
+ "pytest-xdist": ("worker_id", "testrun_uid"),
229
+ "pytest-benchmark": ("benchmark", "benchmark_weave"),
230
+ "pytest-cov": ("cov",),
231
+ "pytest-django": (
232
+ "db",
233
+ "transactional_db",
234
+ "django_db_reset_sequences",
235
+ "django_db_serialized_rollback",
236
+ "django_db_blocker",
237
+ "django_db_setup",
238
+ "django_db_keepdb",
239
+ "django_db_createdb",
240
+ "django_db_modify_db_settings",
241
+ "django_db_use_migrations",
242
+ "client",
243
+ "async_client",
244
+ "rf",
245
+ "async_rf",
246
+ "admin_client",
247
+ "admin_user",
248
+ "django_user_model",
249
+ "django_username_field",
250
+ "settings",
251
+ "live_server",
252
+ "django_assert_num_queries",
253
+ "django_assert_max_num_queries",
254
+ "django_capture_on_commit_callbacks",
255
+ "mailoutbox",
256
+ "django_mail_patch_dns",
257
+ "django_mail_dnsname",
258
+ "django_test_environment",
259
+ ),
260
+ "pytest-flask": (
261
+ "client_class",
262
+ "config",
263
+ "request_ctx",
264
+ "accept_json",
265
+ "accept_jsonp",
266
+ "accept_any",
267
+ "accept_mimetype",
268
+ ),
269
+ "pytest-celery": (
270
+ "celery_app",
271
+ "celery_worker",
272
+ "celery_session_app",
273
+ "celery_session_worker",
274
+ "celery_config",
275
+ "celery_parameters",
276
+ "celery_enable_logging",
277
+ "celery_includes",
278
+ "celery_worker_pool",
279
+ "celery_worker_parameters",
280
+ ),
281
+ "pytest-regressions": (
282
+ "data_regression",
283
+ "file_regression",
284
+ "num_regression",
285
+ "image_regression",
286
+ "dataframe_regression",
287
+ "ndarrays_regression",
288
+ ),
289
+ "pytest-datadir": ("datadir", "shared_datadir", "original_datadir"),
290
+ "pytest-datafiles": ("datafiles",),
291
+ "syrupy": ("snapshot",),
292
+ "pytest-golden": ("golden",),
293
+ "pytest-textual-snapshot": ("snap_compare",),
294
+ "pytest-socket": ("socket_enabled", "socket_disabled"),
295
+ "pytest-console-scripts": ("script_runner",),
296
+ "pytest-check": ("check",),
297
+ "pytest-print": ("printer", "printer_session"),
298
+ "pytest-structlog": ("log",),
299
+ "pytest-metadata": ("metadata",),
300
+ "pytest-base-url": ("base_url",),
301
+ "pytest-variables": ("variables",),
302
+ "pytest-qt": ("qtbot", "qapp", "qapp_args", "qapp_cls", "qtlog", "qtmodeltester"),
303
+ "pytest-playwright": (
304
+ "page",
305
+ "browser",
306
+ "context",
307
+ "playwright",
308
+ "browser_name",
309
+ "browser_type",
310
+ "browser_type_launch_args",
311
+ "browser_context_args",
312
+ "new_context",
313
+ ),
314
+ "pytest-selenium": (
315
+ "selenium",
316
+ "driver",
317
+ "driver_class",
318
+ "driver_args",
319
+ "driver_kwargs",
320
+ "driver_path",
321
+ "chrome_options",
322
+ "firefox_options",
323
+ "capabilities",
324
+ "session_capabilities",
325
+ ),
326
+ "pytest-docker": (
327
+ "docker_ip",
328
+ "docker_services",
329
+ "docker_compose_file",
330
+ "docker_compose_project_name",
331
+ "docker_cleanup",
332
+ "docker_setup",
333
+ ),
334
+ "pytest-postgresql": ("postgresql", "postgresql_proc", "postgresql_noproc"),
335
+ "pytest-mysql": ("mysql", "mysql_proc", "mysql_noproc"),
336
+ "pytest-redis": ("redisdb", "redis_proc", "redis_noproc"),
337
+ "pytest-mongodb": ("mongodb",),
338
+ }
339
+ WELL_KNOWN_PLUGIN_FIXTURES: dict[str, str] = {
340
+ name: dist for dist, names in _PLUGIN_FIXTURES.items() for name in names
341
+ }
342
+
343
+ BUILTIN_FIXTURES = frozenset(
344
+ {
345
+ "cache",
346
+ "capfd",
347
+ "capfdbinary",
348
+ "caplog",
349
+ "capsys",
350
+ "capsysbinary",
351
+ "capteesys",
352
+ "doctest_namespace",
353
+ "monkeypatch",
354
+ "pytestconfig",
355
+ "record_property",
356
+ "record_testsuite_property",
357
+ "record_xml_attribute",
358
+ "recwarn",
359
+ "request",
360
+ "subtests",
361
+ "testdir",
362
+ "pytester",
363
+ "tmp_path",
364
+ "tmp_path_factory",
365
+ "tmpdir",
366
+ "tmpdir_factory",
367
+ }
368
+ )
369
+
370
+ # What pytest's own fixtures request (pytest 9): an in-scope override of a
371
+ # requested name (pip overrides ``tmp_path_factory``) is what a test using
372
+ # the builtin really runs.
373
+ BUILTIN_REQUESTS: dict[str, tuple[str, ...]] = {
374
+ "tmp_path": ("tmp_path_factory",),
375
+ "tmpdir": ("tmp_path",),
376
+ "testdir": ("pytester",),
377
+ "pytester": ("tmp_path_factory", "monkeypatch"),
378
+ }
379
+
380
+ # Fixtures that installed plugins request themselves, without the test
381
+ # naming them, and that projects override to configure the plugin: anyio's
382
+ # plugin adds ``anyio_backend`` to async tests, pytest-django's database
383
+ # fixtures request ``django_db_setup``. The plugins cannot be read, so an
384
+ # in-scope override of one of these is a dependency of every test that can
385
+ # see it, as if autouse. Names tests request directly (``db``, ``client``)
386
+ # are not here: those are followed like any request.
387
+ PLUGIN_REQUESTED: dict[str, str] = {
388
+ name: dist
389
+ for dist, names in {
390
+ "anyio": ("anyio_backend",),
391
+ "pytest-asyncio": ("event_loop_policy", "event_loop"),
392
+ "pytest-django": (
393
+ "django_db_setup",
394
+ "django_db_blocker",
395
+ "django_db_keepdb",
396
+ "django_db_createdb",
397
+ "django_db_use_migrations",
398
+ "django_db_modify_db_settings",
399
+ "django_db_modify_db_settings_parallel_suffix",
400
+ "django_db_modify_db_settings_tox_suffix",
401
+ "django_db_modify_db_settings_xdist_suffix",
402
+ "django_test_environment",
403
+ ),
404
+ "celery": (
405
+ "celery_config",
406
+ "celery_parameters",
407
+ "celery_enable_logging",
408
+ "celery_includes",
409
+ "celery_worker_pool",
410
+ "celery_worker_parameters",
411
+ ),
412
+ "pytest-playwright": ("browser_type_launch_args", "browser_context_args"),
413
+ "pytest-selenium": (
414
+ "driver_args",
415
+ "driver_kwargs",
416
+ "driver_class",
417
+ "driver_path",
418
+ "chrome_options",
419
+ "firefox_options",
420
+ "capabilities",
421
+ "session_capabilities",
422
+ ),
423
+ "pytest-qt": ("qapp_args", "qapp_cls"),
424
+ "pytest-docker": (
425
+ "docker_compose_file",
426
+ "docker_compose_project_name",
427
+ "docker_cleanup",
428
+ "docker_setup",
429
+ ),
430
+ "pytest-datadir": ("original_datadir",),
431
+ }.items()
432
+ for name in names
433
+ }
434
+
435
+ CLASS_SETUP_METHODS = (
436
+ "setup_class",
437
+ "teardown_class",
438
+ "setup_method",
439
+ "teardown_method",
440
+ "setup",
441
+ "teardown",
442
+ "setUp",
443
+ "tearDown",
444
+ "setUpClass",
445
+ "tearDownClass",
446
+ "asyncSetUp",
447
+ "asyncTearDown",
448
+ "setUpTestData",
449
+ # A class-level ``pytest_generate_tests`` parametrizes the class's tests.
450
+ "pytest_generate_tests",
451
+ )
452
+ MODULE_SETUP_FUNCTIONS = (
453
+ "setup_module",
454
+ "teardown_module",
455
+ "setup_function",
456
+ "teardown_function",
457
+ "setUpModule",
458
+ "tearDownModule",
459
+ )
460
+
461
+ # Hooks pytest calls through a node's hook proxy, which consults only the
462
+ # conftests on that node's path. Any other hook a conftest defines
463
+ # (``pytest_collection_modifyitems``, ``pytest_configure``,
464
+ # ``pytest_sessionstart``, ...) runs for the whole session once the conftest
465
+ # is loaded, so it is a dependency of every test.
466
+ PATH_SCOPED_HOOKS = frozenset(
467
+ {
468
+ "pytest_runtest_setup",
469
+ "pytest_runtest_call",
470
+ "pytest_runtest_teardown",
471
+ "pytest_runtest_makereport",
472
+ "pytest_runtest_logreport",
473
+ "pytest_runtest_logstart",
474
+ "pytest_runtest_logfinish",
475
+ "pytest_pyfunc_call",
476
+ "pytest_generate_tests",
477
+ "pytest_make_parametrize_id",
478
+ "pytest_fixture_setup",
479
+ "pytest_fixture_post_finalizer",
480
+ "pytest_collect_file",
481
+ "pytest_collect_directory",
482
+ "pytest_pycollect_makemodule",
483
+ "pytest_pycollect_makeitem",
484
+ "pytest_ignore_collect",
485
+ "pytest_collectstart",
486
+ "pytest_make_collect_report",
487
+ "pytest_itemcollected",
488
+ "pytest_collectreport",
489
+ "pytest_assertrepr_compare",
490
+ "pytest_assertion_pass",
491
+ "pytest_exception_interact",
492
+ }
493
+ )
494
+
495
+
496
+ # --------------------------------------------------------------------------- config
497
+
498
+
499
+ # Test frameworks whose ``*TestCase`` bases contribute no tests of their own:
500
+ # such a base outside the source roots is not reported as unknown.
501
+ TESTCASE_FRAMEWORKS = frozenset(
502
+ {
503
+ "unittest",
504
+ "django",
505
+ "rest_framework",
506
+ "twisted",
507
+ "tornado",
508
+ "asynctest",
509
+ "aiounittest",
510
+ "absl",
511
+ "flask_testing",
512
+ "pyfakefs",
513
+ "aiohttp",
514
+ "IPython",
515
+ }
516
+ )
517
+
518
+
519
+ def _framework_base(
520
+ name: str,
521
+ parts: list[str],
522
+ imports_here: dict[str, tuple[str, str]],
523
+ module_prefixes: dict[str, dict[str, str]],
524
+ owner: Any,
525
+ ) -> bool:
526
+ """Whether a base that resolves to nothing in scope is a test framework's
527
+ ``TestCase`` (imported from one of TESTCASE_FRAMEWORKS), not a class
528
+ discovery merely cannot see (``SharedTestCase = make_base()``)."""
529
+ if len(parts) > 1:
530
+ prefixes = module_prefixes.get(owner.module, {})
531
+ source = prefixes.get(parts[0], parts[0])
532
+ elif name in imports_here:
533
+ source = imports_here[name][0]
534
+ else:
535
+ return False
536
+ return source.split(".")[0] in TESTCASE_FRAMEWORKS
537
+
538
+
539
+ def _assigned(stmt: ast.stmt) -> tuple[str, ast.expr] | None:
540
+ """``(name, value)`` for ``name = <value>``, else None."""
541
+ if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1:
542
+ target = stmt.targets[0]
543
+ if isinstance(target, ast.Name):
544
+ return target.id, stmt.value
545
+ return None
546
+
547
+
548
+ def _split(value: Any) -> tuple[str, ...]:
549
+ """An option's values: a list as TOML gives it, or an INI string split
550
+ the way pytest splits ``args`` options and ``addopts`` (shell-like, so
551
+ ``--doctest-glob="*.rst"`` loses its quotes)."""
552
+ if isinstance(value, str):
553
+ try:
554
+ return tuple(shlex.split(value))
555
+ except ValueError: # an unbalanced quote: pytest would fail; be lenient
556
+ return tuple(value.split())
557
+ if isinstance(value, list):
558
+ return tuple(str(v) for v in value)
559
+ return ()
560
+
561
+
562
+ def _toml_table(data: Any, *keys: str) -> dict[str, Any] | None:
563
+ """``data[keys[0]][keys[1]]...`` when every step is a table, else None."""
564
+ for key in keys:
565
+ if not isinstance(data, dict):
566
+ return None
567
+ data = data.get(key)
568
+ return data if isinstance(data, dict) else None
569
+
570
+
571
+ def _load_toml(raw: bytes) -> dict[str, Any]:
572
+ try:
573
+ return tomllib.loads(raw.decode("utf-8"))
574
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError):
575
+ return {}
576
+
577
+
578
+ # Configuration that switches on an installed plugin's own collection of
579
+ # files discovery does not read: an ini key, or an option in addopts or
580
+ # after ``--``. Reported (``plugin_collects_files``); a recording settles it.
581
+ COLLECTING_PLUGIN_KEYS = {
582
+ "typing_checkers": "pytest-typing (collects type-check cases from .md files)",
583
+ "nb_test_files": "pytest-notebook (collects notebooks)",
584
+ "doctest_plus": "pytest-doctestplus (collects doctests from text files)",
585
+ "doctest_rst": "pytest-doctestplus (collects doctests from .rst files)",
586
+ }
587
+ COLLECTING_PLUGIN_OPTIONS = {
588
+ "--nbval": "nbval (collects notebooks)",
589
+ "--nbval-lax": "nbval (collects notebooks)",
590
+ "--markdown-docs": "pytest-markdown-docs (collects code blocks from .md files)",
591
+ "--doctest-rst": "pytest-doctestplus (collects doctests from .rst files)",
592
+ "--doctest-plus": "pytest-doctestplus (collects doctests)",
593
+ "--mypy-testing-base": "pytest-mypy-plugins (collects .yml cases)",
594
+ }
595
+
596
+ # pytest options that change what is collected in ways discovery does not
597
+ # model: given after ``--``, they make the target list possibly short.
598
+ UNMODELLED_COLLECTION_OPTIONS = (
599
+ "-c",
600
+ "--config-file",
601
+ "-o",
602
+ "--override-ini",
603
+ "--rootdir",
604
+ "--pyargs",
605
+ )
606
+
607
+
608
+ def read_pytest_config(snapshot: Snapshot, runner_args: tuple[str, ...] = ()) -> dict[str, Any]:
609
+ """Return python_files/classes/functions/testpaths and where they came from."""
610
+ config: dict[str, Any] = {
611
+ "source": None,
612
+ "python_files": DEFAULT_PYTHON_FILES,
613
+ "python_classes": DEFAULT_PYTHON_CLASSES,
614
+ "python_functions": DEFAULT_PYTHON_FUNCTIONS,
615
+ "testpaths": (),
616
+ "entry_point_plugins": (), # pytest11 entry points defined by the project itself
617
+ "addopts_plugins": (), # ``-p name`` entries in addopts
618
+ "doctest_modules": False, # ``--doctest-modules`` in addopts
619
+ "import_mode": "prepend", # ``--import-mode`` in addopts
620
+ "collecting_plugins": (), # configured plugins with collection of their own
621
+ "doctest_globs": ("test*.txt",), # ``--doctest-glob`` patterns
622
+ "norecursedirs": DEFAULT_NORECURSEDIRS,
623
+ "usefixtures": (), # the ini option: fixtures every test requests
624
+ "ini_addopts": (), # ``addopts`` as configured
625
+ }
626
+ section: dict[str, Any] | None = None
627
+ files = snapshot.config_files
628
+ # pytest's precedence (pytest 9): pytest.toml, .pytest.toml, pytest.ini,
629
+ # .pytest.ini, pyproject.toml, tox.ini, setup.cfg. The first four are the
630
+ # config file whenever present, even empty.
631
+ for name in ("pytest.toml", ".pytest.toml"):
632
+ if section is None and name in files:
633
+ section = _toml_table(_load_toml(files[name]), "pytest") or {}
634
+ config["source"] = name
635
+ for name in ("pytest.ini", ".pytest.ini"):
636
+ if section is None and name in files:
637
+ section = _ini_section(files[name], "pytest") or {}
638
+ config["source"] = name
639
+ if section is None and "pyproject.toml" in files:
640
+ tool = _toml_table(_load_toml(files["pyproject.toml"]), "tool", "pytest") or {}
641
+ ini_options = tool.get("ini_options")
642
+ if isinstance(ini_options, dict):
643
+ section = ini_options
644
+ config["source"] = "pyproject.toml"
645
+ elif tool:
646
+ # pytest >= 9 native TOML table: [tool.pytest] with real TOML values.
647
+ section = {k: v for k, v in tool.items() if k != "ini_options"}
648
+ config["source"] = "pyproject.toml [tool.pytest]"
649
+ if section is None and "tox.ini" in files:
650
+ section = _ini_section(files["tox.ini"], "pytest")
651
+ if section is not None:
652
+ config["source"] = "tox.ini"
653
+ if section is None and "setup.cfg" in files:
654
+ section = _ini_section(files["setup.cfg"], "tool:pytest")
655
+ if section is not None:
656
+ config["source"] = "setup.cfg"
657
+ if section is None and runner_args:
658
+ section = {}
659
+ if section is not None and (section or runner_args):
660
+ for key in (
661
+ "python_files",
662
+ "python_classes",
663
+ "python_functions",
664
+ "testpaths",
665
+ "norecursedirs",
666
+ "usefixtures",
667
+ ):
668
+ if key in section:
669
+ values = _split(section[key])
670
+ if values:
671
+ config[key] = values
672
+ config["ini_addopts"] = _split(section.get("addopts", ""))
673
+ addopts = config["ini_addopts"] + tuple(runner_args)
674
+ config["collecting_plugins"] = tuple(
675
+ sorted(
676
+ {COLLECTING_PLUGIN_KEYS[k] for k in section if k in COLLECTING_PLUGIN_KEYS}
677
+ | {
678
+ COLLECTING_PLUGIN_OPTIONS[a.split("=", 1)[0]]
679
+ for a in addopts
680
+ if a.split("=", 1)[0] in COLLECTING_PLUGIN_OPTIONS
681
+ }
682
+ )
683
+ )
684
+ config["addopts_plugins"] = tuple(_addopts_plugins(addopts))
685
+ config["doctest_modules"] = "--doctest-modules" in addopts
686
+ modes = _option_values(addopts, "--import-mode")
687
+ if modes:
688
+ config["import_mode"] = modes[-1]
689
+ globs = _option_values(addopts, "--doctest-glob")
690
+ if globs:
691
+ config["doctest_globs"] = tuple(globs)
692
+ config["entry_point_plugins"] = tuple(_entry_point_plugins(files))
693
+ return config
694
+
695
+
696
+ def _entry_point_plugins(files: dict[str, bytes]) -> list[str]:
697
+ """Modules the project registers as pytest plugins (``pytest11`` entry
698
+ points in pyproject.toml or setup.cfg, the root's or a sibling package's
699
+ in the repository, which a development environment installs too).
700
+ pytest loads them for every test session, so their fixtures and hooks
701
+ are visible everywhere."""
702
+ modules: list[str] = []
703
+ for path, raw in sorted(files.items(), key=lambda item: (item[0].count("/"), item[0])):
704
+ name = PurePosixPath(path).name
705
+ if name == "pyproject.toml":
706
+ data = _load_toml(raw)
707
+ for keys in (
708
+ ("project", "entry-points", "pytest11"),
709
+ ("tool", "poetry", "plugins", "pytest11"),
710
+ ):
711
+ entries = _toml_table(data, *keys) or {}
712
+ modules += [str(v).split(":", 1)[0].strip() for v in entries.values()]
713
+ elif name == "setup.cfg":
714
+ section = _ini_section(raw, "options.entry_points") or {}
715
+ for line in str(section.get("pytest11", "")).splitlines():
716
+ if "=" in line:
717
+ modules.append(line.split("=", 1)[1].split(":", 1)[0].strip())
718
+ return [m for m in dict.fromkeys(modules) if m]
719
+
720
+
721
+ BUILTIN_PLUGINS = frozenset({"pytester", "pytest", "_pytest"})
722
+
723
+
724
+ # pytest's default ``norecursedirs``.
725
+ DEFAULT_NORECURSEDIRS = (
726
+ "*.egg",
727
+ ".*",
728
+ "_darcs",
729
+ "build",
730
+ "CVS",
731
+ "dist",
732
+ "node_modules",
733
+ "venv",
734
+ "{arch}",
735
+ )
736
+
737
+
738
+ def _collected_dir(path: str, norecursedirs: tuple[str, ...]) -> bool:
739
+ """Whether pytest recurses into every directory on ``path``."""
740
+ directories = PurePosixPath(path).parts[:-1]
741
+ return not any(fnmatch(part, pattern) for part in directories for pattern in norecursedirs)
742
+
743
+
744
+ def _option_values(addopts: tuple[str, ...], option: str) -> list[str]:
745
+ """Values of ``--option=value`` / ``--option value`` in addopts."""
746
+ values: list[str] = []
747
+ tokens = list(addopts)
748
+ for i, token in enumerate(tokens):
749
+ if token.startswith(option + "="):
750
+ values.append(token.split("=", 1)[1])
751
+ elif token == option and i + 1 < len(tokens):
752
+ values.append(tokens[i + 1])
753
+ return values
754
+
755
+
756
+ def _unmodelled_addopts(addopts: tuple[str, ...], paths: set[str]) -> list[str]:
757
+ """Entries of the configured ``addopts`` that change collection in ways
758
+ discovery does not model: an override of the configuration (``-o``,
759
+ ``-c``, ``--rootdir``, ``--pyargs``) or a path, which pytest collects
760
+ from instead of ``testpaths`` (a file named there is collected whatever
761
+ ``python_files`` says)."""
762
+ found: list[str] = []
763
+ for token in addopts:
764
+ option = token.split("=", 1)[0]
765
+ if option in UNMODELLED_COLLECTION_OPTIONS or (
766
+ option.startswith("-o") and option != "-o" and not option.startswith("--")
767
+ ):
768
+ found.append(token)
769
+ elif not token.startswith("-"):
770
+ tp = _normalise_testpath(token.split("::", 1)[0])
771
+ if tp not in ("", ".") and (tp in paths or any(p.startswith(tp + "/") for p in paths)):
772
+ found.append(token)
773
+ return found
774
+
775
+
776
+ def _addopts_plugins(addopts: tuple[str, ...]) -> list[str]:
777
+ """Plugins loaded early through ``-p name`` / ``-pname`` in addopts
778
+ (``-p no:name`` disables one and is ignored)."""
779
+ names: list[str] = []
780
+ tokens = list(addopts)
781
+ for i, token in enumerate(tokens):
782
+ name = None
783
+ if token == "-p" and i + 1 < len(tokens):
784
+ name = tokens[i + 1]
785
+ elif token.startswith("-p") and len(token) > 2 and not token.startswith("--"):
786
+ name = token[2:]
787
+ if name and not name.startswith("no:") and name not in names:
788
+ names.append(name)
789
+ return names
790
+
791
+
792
+ # Bases from the standard library that contribute no test methods: worth no
793
+ # ``unknown_base_class`` note (scrapy's spiders are 133 subclasses of ``ABC``).
794
+ NO_TEST_BASES = frozenset(
795
+ {
796
+ "ABC",
797
+ "ABCMeta",
798
+ "BaseException",
799
+ "Enum",
800
+ "Exception",
801
+ "Generic",
802
+ "IntEnum",
803
+ "NamedTuple",
804
+ "Protocol",
805
+ "StrEnum",
806
+ "TypedDict",
807
+ "object",
808
+ }
809
+ )
810
+
811
+
812
+ def _star_names(tree: ast.Module) -> list[str]:
813
+ """What ``from <module> import *`` binds: ``__all__`` when it is set once
814
+ to a literal list of strings; with no ``__all__``, every name the module
815
+ binds at top level (definitions, imports and assignments alike) that does
816
+ not start with an underscore; with an ``__all__`` built otherwise
817
+ (``base.__all__ + [...]``, ``__all__ += [...]``, ``__all__.extend``),
818
+ both and every string it names: a superset."""
819
+ literal: list[str] | None = None
820
+ dynamic = False
821
+ strings: list[str] = []
822
+ for stmt in iter_scope_statements(tree.body):
823
+ if isinstance(stmt, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
824
+ targets = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]
825
+ if not any(isinstance(t, ast.Name) and t.id == "__all__" for t in targets):
826
+ continue
827
+ value = stmt.value
828
+ if (
829
+ not isinstance(stmt, ast.AugAssign)
830
+ and literal is None
831
+ and isinstance(value, (ast.List, ast.Tuple))
832
+ and all(
833
+ isinstance(e, ast.Constant) and isinstance(e.value, str) for e in value.elts
834
+ )
835
+ ):
836
+ literal = [e.value for e in value.elts if isinstance(e, ast.Constant)]
837
+ continue
838
+ elif not (
839
+ isinstance(stmt, ast.Expr)
840
+ and any(isinstance(n, ast.Name) and n.id == "__all__" for n in ast.walk(stmt))
841
+ ):
842
+ continue
843
+ dynamic = True
844
+ strings += [
845
+ n.value
846
+ for n in ast.walk(stmt)
847
+ if isinstance(n, ast.Constant) and isinstance(n.value, str)
848
+ ]
849
+ if literal is not None and not dynamic:
850
+ return literal
851
+ bound: list[str] = []
852
+ for stmt in iter_scope_statements(tree.body):
853
+ if isinstance(stmt, DEF_NODES):
854
+ bound.append(stmt.name)
855
+ elif isinstance(stmt, (ast.Import, ast.ImportFrom)):
856
+ bound += [a.asname or a.name.split(".")[0] for a in stmt.names if a.name != "*"]
857
+ elif (assigned := _assigned(stmt)) is not None:
858
+ bound.append(assigned[0])
859
+ public = [n for n in bound if not n.startswith("_")]
860
+ return list(dict.fromkeys([*(literal or []), *strings, *public]))
861
+
862
+
863
+ def _absolute_module(parsed: ParsedModule, node: ast.ImportFrom) -> str:
864
+ """Absolute module of a ``from ... import`` statement in ``parsed``."""
865
+ return resolve_relative_module(
866
+ parsed.module, parsed.path.endswith("__init__.py"), node.module, node.level
867
+ )
868
+
869
+
870
+ def _ini_section(raw: bytes, name: str) -> dict[str, Any] | None:
871
+ parser = configparser.ConfigParser(interpolation=None)
872
+ try:
873
+ parser.read_string(raw.decode("utf-8"))
874
+ except (configparser.Error, UnicodeDecodeError):
875
+ return None
876
+ if not parser.has_section(name):
877
+ return None
878
+ return dict(parser.items(name))
879
+
880
+
881
+ # Hooks that turn files or objects into tests by a plugin's own rules, so the
882
+ # target list cannot be the suite (scrapy's docs/conftest.py binds
883
+ # ``pytest_collect_file`` to a Sybil instance). ``pytest_generate_tests`` is
884
+ # not one: it multiplies a test function that is already a target.
885
+ COLLECT_HOOKS = frozenset(
886
+ {"pytest_collect_file", "pytest_collect_directory", "pytest_pycollect_makeitem"}
887
+ )
888
+
889
+
890
+ def _matches_python_file(path: str, pattern: str) -> bool:
891
+ """pytest's ``fnmatch_ex``: a pattern without a path separator matches the
892
+ basename; one with a separator matches the path. pytest matches absolute
893
+ paths and so prefixes a relative pattern with ``*/``, which is what makes
894
+ scrapy's ``test_*/__init__.py`` match ``tests/test_settings/__init__.py``;
895
+ the path here is repo-relative, so both forms are tried."""
896
+ if "/" in pattern:
897
+ pattern = pattern.lstrip("./")
898
+ return fnmatch(path, pattern) or fnmatch(path, f"*/{pattern}")
899
+ return fnmatch(PurePosixPath(path).name, pattern)
900
+
901
+
902
+ # Module-level names pytest reads from a test module or conftest; when they are
903
+ # variable symbols, every test in the module depends on them.
904
+ MODULE_LEVEL_PYTEST_NAMES = (
905
+ "pytestmark",
906
+ "pytest_plugins",
907
+ "collect_ignore",
908
+ "collect_ignore_glob",
909
+ )
910
+
911
+
912
+ def _matches(patterns: tuple[str, ...], name: str) -> bool:
913
+ for pattern in patterns:
914
+ if any(ch in pattern for ch in "*?["):
915
+ if fnmatch(name, pattern):
916
+ return True
917
+ elif name.startswith(pattern):
918
+ return True
919
+ return False
920
+
921
+
922
+ # --------------------------------------------------------------------------- model
923
+
924
+
925
+ @dataclass(frozen=True)
926
+ class Fixture:
927
+ name: str
928
+ symbol: str
929
+ autouse: bool
930
+ requests: tuple[str, ...]
931
+
932
+
933
+ @dataclass
934
+ class ModuleFacts:
935
+ parsed: ParsedModule
936
+ fixtures: dict[str, Fixture] = field(default_factory=dict)
937
+ hooks: list[str] = field(default_factory=list)
938
+ # The hooks among them pytest calls for the whole session, not through a
939
+ # node's path (see PATH_SCOPED_HOOKS).
940
+ session_hooks: list[str] = field(default_factory=list)
941
+ # Collection hooks this module binds (as a def or an assignment): they
942
+ # make tests out of files or objects these rules do not model.
943
+ collect_hooks: list[str] = field(default_factory=list)
944
+ plugins: list[str] = field(default_factory=list)
945
+ usefixtures: tuple[str, ...] = ()
946
+ setup_functions: list[str] = field(default_factory=list)
947
+ # Attribute name -> (fixture, whether it names itself): what pytest finds
948
+ # bound in the module's namespace. Fixtures defined here start it; names
949
+ # bound to them by alias or import are added by _link_fixtures.
950
+ fixture_attrs: dict[str, tuple[Fixture, bool]] = field(default_factory=dict)
951
+ # Module-level bindings that may bind a fixture, in order: ("alias",
952
+ # name, "", other_name), ("import", name, module, imported_name) and
953
+ # ("star", "", module, "").
954
+ bindings: list[tuple[str, str, str, str]] = field(default_factory=list)
955
+
956
+
957
+ def _is_fixture(node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[bool, str | None, bool]:
958
+ """(is_fixture, explicit name, autouse)."""
959
+ for dec in node.decorator_list:
960
+ parts, call = decorator_chain(dec)
961
+ if parts and parts[-1] in ("fixture", "yield_fixture"):
962
+ name_node = keyword_value(call, "name")
963
+ name = (
964
+ name_node.value
965
+ if isinstance(name_node, ast.Constant) and isinstance(name_node.value, str)
966
+ else None
967
+ )
968
+ return True, name, _autouse(keyword_value(call, "autouse"))
969
+ return False, None, False
970
+
971
+
972
+ def _autouse(node: ast.expr | None) -> bool:
973
+ """Whether ``autouse=`` may be true: anything but a literal false value
974
+ (``autouse=HAS_BLOCKBUSTER`` is autouse whenever the flag is set)."""
975
+ return node is not None and not (isinstance(node, ast.Constant) and not node.value)
976
+
977
+
978
+ @dataclass(frozen=True)
979
+ class Marks:
980
+ """The mark-derived facts that flow from module to class to function."""
981
+
982
+ usefixtures: tuple[str, ...] = ()
983
+ parametrized: frozenset[str] = frozenset() # argnames supplied by parametrize
984
+ indirect: frozenset[str] = frozenset() # parametrized names that are still fixtures
985
+
986
+ def __add__(self, other: Marks) -> Marks:
987
+ return Marks(
988
+ self.usefixtures + other.usefixtures,
989
+ self.parametrized | other.parametrized,
990
+ self.indirect | other.indirect,
991
+ )
992
+
993
+ @property
994
+ def supplied(self) -> frozenset[str]:
995
+ """Names whose values ``parametrize`` supplies directly: they replace
996
+ any fixture of that name throughout the test's closure."""
997
+ return self.parametrized - self.indirect
998
+
999
+
1000
+ NO_MARKS = Marks()
1001
+
1002
+
1003
+ def _split_argnames(node: ast.expr) -> list[str]:
1004
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
1005
+ return [n.strip() for n in node.value.replace(",", " ").split() if n.strip()]
1006
+ return string_literals([node])
1007
+
1008
+
1009
+ # Expands a decorator or ``pytestmark`` entry that names a stored mark
1010
+ # (``@skip_pyarrow`` after ``skip_pyarrow = pytest.mark.usefixtures(...)``)
1011
+ # into the mark expressions it stands for; None or empty when it names none.
1012
+ MarkExpander = Callable[[ast.expr], "list[ast.expr] | None"]
1013
+
1014
+
1015
+ def _marks_from_expressions(exprs: list[ast.expr], expand: MarkExpander | None = None) -> Marks:
1016
+ use: list[str] = []
1017
+ parametrized: set[str] = set()
1018
+ indirect: set[str] = set()
1019
+ if expand is not None:
1020
+ exprs = [e for x in exprs for e in (expand(x) or [x])]
1021
+ for expr in exprs:
1022
+ parts, call = decorator_chain(expr)
1023
+ if call is None or len(parts) < 2 or parts[-2] != "mark":
1024
+ continue
1025
+ if parts[-1] == "usefixtures":
1026
+ use.extend(string_literals(list(call.args)))
1027
+ elif parts[-1] == "parametrize" and call.args:
1028
+ names = _split_argnames(call.args[0])
1029
+ parametrized.update(names)
1030
+ ind = keyword_value(call, "indirect")
1031
+ if isinstance(ind, ast.Constant) and ind.value is True:
1032
+ indirect.update(names)
1033
+ elif isinstance(ind, (ast.List, ast.Tuple)):
1034
+ indirect.update(string_literals(list(ind.elts)))
1035
+ return Marks(tuple(use), frozenset(parametrized), frozenset(indirect))
1036
+
1037
+
1038
+ def _marks_from_pytestmark(body: list[ast.stmt], expand: MarkExpander | None = None) -> Marks:
1039
+ exprs: list[ast.expr] = []
1040
+ for name, value in scope_assignments(body):
1041
+ if name == "pytestmark":
1042
+ exprs += list(value.elts) if isinstance(value, (ast.List, ast.Tuple)) else [value]
1043
+ return _marks_from_expressions(exprs, expand)
1044
+
1045
+
1046
+ def _usefixtures_from_pytestmark(body: list[ast.stmt]) -> tuple[str, ...]:
1047
+ return _marks_from_pytestmark(body).usefixtures
1048
+
1049
+
1050
+ def _injected_patch_count(decorators: list[ast.expr]) -> int:
1051
+ """Number of ``mock.patch``/``patch.object`` decorators that inject an argument."""
1052
+ count = 0
1053
+ for dec in decorators:
1054
+ parts, call = decorator_chain(dec)
1055
+ if call is None or not parts:
1056
+ continue
1057
+ if parts[-1] == "patch":
1058
+ positional_new = len(call.args) >= 2
1059
+ elif len(parts) >= 2 and parts[-2] == "patch" and parts[-1] == "object":
1060
+ positional_new = len(call.args) >= 3
1061
+ else:
1062
+ continue
1063
+ if not positional_new and keyword_value(call, "new") is None:
1064
+ count += 1
1065
+ return count
1066
+
1067
+
1068
+ def _plugins_from_body(body: list[ast.stmt]) -> list[str]:
1069
+ for name, value in scope_assignments(body):
1070
+ if name == "pytest_plugins":
1071
+ return string_literals([value])
1072
+ return []
1073
+
1074
+
1075
+ def _fixture_requests(
1076
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
1077
+ is_method: bool,
1078
+ marks: Marks = NO_MARKS,
1079
+ class_injected: int = 0,
1080
+ ) -> tuple[str, ...]:
1081
+ """Parameter names pytest would look up as fixtures.
1082
+
1083
+ Mirrors ``getfuncargnames``: drops ``self``, parameters with defaults,
1084
+ arguments injected by ``mock.patch`` decorators (``class_injected`` of
1085
+ them by decorators on the enclosing class and its bases, which
1086
+ ``unittest.mock`` applies to every ``test*`` method), and names supplied
1087
+ by ``parametrize`` unless they are marked ``indirect``. Fixtures the
1088
+ body asks for by literal name (``request.getfixturevalue("db")``, how
1089
+ pytest-django's autouse ``_django_db_marker`` reaches
1090
+ ``_django_db_helper``) count as requests too.
1091
+ """
1092
+ args = node.args
1093
+ positional = args.posonlyargs + args.args
1094
+ if is_method and any(
1095
+ isinstance(d, ast.Name) and d.id == "staticmethod" for d in node.decorator_list
1096
+ ):
1097
+ is_method = False # no ``self`` to drop
1098
+ n_defaults = len(args.defaults)
1099
+ required = [a.arg for a in positional[: len(positional) - n_defaults]]
1100
+ required += [a.arg for a, d in zip(args.kwonlyargs, args.kw_defaults, strict=True) if d is None]
1101
+ if is_method and required:
1102
+ required = required[1:]
1103
+ injected = _injected_patch_count(node.decorator_list)
1104
+ if node.name.startswith("test"):
1105
+ injected += class_injected
1106
+ if injected:
1107
+ required = required[injected:]
1108
+ skip = set(marks.supplied)
1109
+ # hypothesis ``@given``: keyword strategies fill parameters by name,
1110
+ # positional strategies fill the *last* parameters (from the right).
1111
+ given_positional, given_keywords = _given_arguments(node.decorator_list)
1112
+ skip |= given_keywords
1113
+ remaining = [p for p in required if p not in skip]
1114
+ if given_positional:
1115
+ skip |= set(remaining[len(remaining) - given_positional :])
1116
+ names = [p for p in required if p != "request" and p not in skip]
1117
+ return tuple(dict.fromkeys(names + _getfixturevalue_names(node)))
1118
+
1119
+
1120
+ # A request for any fixture visible from the test: what a
1121
+ # ``getfixturevalue`` whose argument is not a literal may ask for.
1122
+ ANY_FIXTURE = "*"
1123
+
1124
+
1125
+ def _getfixturevalue_names(node: ast.AST) -> list[str]:
1126
+ """Fixture names requested as ``<request>.getfixturevalue("name")`` with a
1127
+ literal, anywhere in the body (nested functions included); ANY_FIXTURE
1128
+ for one whose argument is not a literal (``getfixturevalue(name)`` over a
1129
+ parametrized list of fixture names)."""
1130
+ found: list[str] = []
1131
+ if getattr(node, NO_GETFIXTUREVALUE, False):
1132
+ return found # its module's source never says getfixturevalue
1133
+ for inner in ast.walk(node):
1134
+ if (
1135
+ isinstance(inner, ast.Call)
1136
+ and isinstance(inner.func, ast.Attribute)
1137
+ and inner.func.attr == "getfixturevalue"
1138
+ ):
1139
+ arg = inner.args[0] if inner.args else keyword_value(inner, "argname")
1140
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
1141
+ found.append(arg.value)
1142
+ else:
1143
+ found.append(ANY_FIXTURE)
1144
+ return found
1145
+
1146
+
1147
+ def _given_arguments(decorators: list[ast.expr]) -> tuple[int, set[str]]:
1148
+ positional = 0
1149
+ keywords: set[str] = set()
1150
+ for dec in decorators:
1151
+ parts, call = decorator_chain(dec)
1152
+ if call is not None and parts and parts[-1] == "given":
1153
+ positional += len(call.args)
1154
+ keywords |= {k.arg for k in call.keywords if k.arg is not None}
1155
+ return positional, keywords
1156
+
1157
+
1158
+ def _collect_facts(parsed: ParsedModule) -> ModuleFacts:
1159
+ facts = ModuleFacts(parsed=parsed)
1160
+ body = parsed.tree.body
1161
+ for func in scope_functions(body):
1162
+ is_fixture, explicit, autouse = _is_fixture(func)
1163
+ symbol = parsed.member_id(func.name)
1164
+ if is_fixture:
1165
+ name = explicit or func.name
1166
+ fixture = Fixture(name, symbol, autouse, _fixture_requests(func, False))
1167
+ facts.fixtures[name] = fixture
1168
+ facts.fixture_attrs[func.name] = (fixture, explicit is not None)
1169
+ elif func.name.startswith("pytest_"):
1170
+ facts.hooks.append(symbol)
1171
+ if func.name not in PATH_SCOPED_HOOKS:
1172
+ facts.session_hooks.append(symbol)
1173
+ if func.name in COLLECT_HOOKS:
1174
+ facts.collect_hooks.append(func.name)
1175
+ elif func.name in MODULE_SETUP_FUNCTIONS:
1176
+ facts.setup_functions.append(symbol)
1177
+ # ``pytest_collect_file = Sybil(...).pytest()``: a collection hook bound
1178
+ # to a value rather than defined as a function.
1179
+ for name, _ in scope_assignments(body):
1180
+ if name in COLLECT_HOOKS and name not in facts.collect_hooks:
1181
+ facts.collect_hooks.append(name)
1182
+ # ``mocker = pytest.fixture(scope="function")(_mocker)``: a fixture made by
1183
+ # calling the decorator on an in-module function and binding the result;
1184
+ # ``x = pytest.fixture(f, autouse=True)`` is the same in one call.
1185
+ functions = {f.name: f for f in scope_functions(body)}
1186
+ for name, value in scope_assignments(body):
1187
+ if not isinstance(value, ast.Call):
1188
+ continue
1189
+ parts, call = decorator_chain(value.func if isinstance(value.func, ast.Call) else value)
1190
+ if not parts or parts[-1] not in ("fixture", "yield_fixture") or len(value.args) != 1:
1191
+ continue
1192
+ target = value.args[0]
1193
+ if not (isinstance(target, ast.Name) and target.id in functions):
1194
+ continue
1195
+ func = functions[target.id]
1196
+ explicit = keyword_value(call, "name")
1197
+ explicit_name = (
1198
+ explicit.value
1199
+ if isinstance(explicit, ast.Constant) and isinstance(explicit.value, str)
1200
+ else None
1201
+ )
1202
+ names_itself = explicit_name is not None
1203
+ fixture_name = explicit_name or name
1204
+ autouse = _autouse(keyword_value(call, "autouse"))
1205
+ fixture = Fixture(
1206
+ fixture_name, parsed.member_id(func.name), autouse, _fixture_requests(func, False)
1207
+ )
1208
+ facts.fixtures[fixture_name] = fixture
1209
+ facts.fixture_attrs[name] = (fixture, names_itself)
1210
+ # Names bound to a fixture defined elsewhere (``box2 = box``, ``from
1211
+ # pkg.conftest import engine``): pytest registers a fixture under every
1212
+ # name the module binds it to (_link_fixtures resolves them).
1213
+ for stmt in iter_scope_statements(body):
1214
+ if isinstance(stmt, ast.ImportFrom):
1215
+ source = _absolute_module(parsed, stmt)
1216
+ for alias in stmt.names:
1217
+ if alias.name == "*":
1218
+ facts.bindings.append(("star", "", source, ""))
1219
+ else:
1220
+ facts.bindings.append(
1221
+ ("import", alias.asname or alias.name, source, alias.name)
1222
+ )
1223
+ elif (
1224
+ isinstance(stmt, ast.Assign)
1225
+ and len(stmt.targets) == 1
1226
+ and isinstance(stmt.targets[0], ast.Name)
1227
+ and isinstance(stmt.value, ast.Name)
1228
+ ):
1229
+ facts.bindings.append(("alias", stmt.targets[0].id, "", stmt.value.id))
1230
+ facts.plugins = _plugins_from_body(body)
1231
+ facts.usefixtures = _usefixtures_from_pytestmark(body)
1232
+ return facts
1233
+
1234
+
1235
+ def _class_level(owners: list[tuple[ast.ClassDef, str]]) -> dict[str, Fixture]:
1236
+ """The fixtures pytest registers for a class, which it reads from
1237
+ ``dir(cls)``: ``owners`` is the class and its resolved bases, nearest
1238
+ first, wherever they are defined. Each attribute comes from the nearest
1239
+ owner defining it, so an override hides the base's fixture (and a
1240
+ fixture requesting its own name reaches past both); two attributes
1241
+ registering one name resolve to the alphabetically last, as pytest
1242
+ registers them in ``dir`` order."""
1243
+ attrs: dict[str, tuple[ast.FunctionDef | ast.AsyncFunctionDef, str]] = {}
1244
+ for owner, owner_id in owners:
1245
+ for func in scope_functions(owner.body):
1246
+ attrs.setdefault(func.name, (func, owner_id))
1247
+ level: dict[str, Fixture] = {}
1248
+ for attr, (func, owner_id) in sorted(attrs.items()):
1249
+ is_fixture, explicit, autouse = _is_fixture(func)
1250
+ if is_fixture:
1251
+ name = explicit or attr
1252
+ level[name] = Fixture(
1253
+ name, f"{owner_id}.{attr}", autouse, _fixture_requests(func, True)
1254
+ )
1255
+ return level
1256
+
1257
+
1258
+ # --------------------------------------------------------------------------- discovery
1259
+
1260
+
1261
+ class _Resolver:
1262
+ """Fixture lookup along pytest's scope chain for one test module."""
1263
+
1264
+ def __init__(
1265
+ self,
1266
+ module: ModuleFacts,
1267
+ conftests: list[ModuleFacts],
1268
+ plugins: list[ModuleFacts],
1269
+ options: DiscoveryOptions,
1270
+ unresolved: Counter[str],
1271
+ assumed: Counter[str],
1272
+ usefixtures: tuple[str, ...] = (),
1273
+ ) -> None:
1274
+ self.module = module
1275
+ self.conftests = conftests
1276
+ self.plugins = plugins
1277
+ self.options = options
1278
+ self.unresolved = unresolved
1279
+ self.assumed = assumed # external fixture name -> requesting tests
1280
+ # The ini option ``usefixtures``: requested by every test.
1281
+ self.usefixtures = usefixtures
1282
+
1283
+ def plugin_requested(self, name: str) -> bool:
1284
+ """Whether an installed plugin requests this name itself
1285
+ (``PLUGIN_REQUESTED``): an in-scope override of it is then a
1286
+ dependency of every test it is visible to, like an autouse fixture."""
1287
+ return self.options.well_known_fixtures and name in PLUGIN_REQUESTED
1288
+
1289
+ def chain(self, class_levels: list[dict[str, Fixture]]) -> list[dict[str, Fixture]]:
1290
+ levels = list(class_levels) # innermost class first
1291
+ levels.append(self.module.fixtures)
1292
+ levels.extend(c.fixtures for c in self.conftests)
1293
+ levels.extend(p.fixtures for p in self.plugins)
1294
+ return levels
1295
+
1296
+ def lifecycle(
1297
+ self,
1298
+ class_levels: list[dict[str, Fixture]],
1299
+ requests: list[str],
1300
+ supplied: frozenset[str] = frozenset(),
1301
+ ) -> list[str]:
1302
+ """Fixtures the test's closure reaches. A ``supplied`` name (directly
1303
+ parametrized on the test) is a parameter at every depth, as pytest
1304
+ replaces the fixture of that name and prunes what it requests."""
1305
+ levels = self.chain(class_levels)
1306
+ deps: list[str] = []
1307
+ seen: set[tuple[str, int]] = set()
1308
+ # (name, first level to search): a fixture that requests its own name
1309
+ # (``def db(db)``) refers to the next definition outward.
1310
+ queue: list[tuple[str, int]] = [(name, 0) for name in [*self.usefixtures, *requests]]
1311
+ for level in levels:
1312
+ for fixture in level.values():
1313
+ if fixture.autouse or self.plugin_requested(fixture.name):
1314
+ queue.append((fixture.name, 0))
1315
+ while queue:
1316
+ name, start = queue.pop(0)
1317
+ if (name, start) in seen or name in supplied:
1318
+ continue
1319
+ seen.add((name, start))
1320
+ if name == ANY_FIXTURE:
1321
+ queue.extend((other, 0) for level in levels for other in level)
1322
+ continue
1323
+ found = next(
1324
+ ((i, lvl[name]) for i, lvl in enumerate(levels) if i >= start and name in lvl),
1325
+ None,
1326
+ )
1327
+ if found is not None:
1328
+ level_index, fixture = found
1329
+ deps.append(fixture.symbol)
1330
+ for req in fixture.requests:
1331
+ queue.append((req, level_index + 1 if req == name else 0))
1332
+ elif name in BUILTIN_FIXTURES:
1333
+ queue.extend((req, 0) for req in BUILTIN_REQUESTS.get(name, ()))
1334
+ elif name in self.options.external_fixtures or (
1335
+ self.options.well_known_fixtures and name in WELL_KNOWN_PLUGIN_FIXTURES
1336
+ ):
1337
+ self.assumed[name] += 1
1338
+ else:
1339
+ self.unresolved[name] += 1
1340
+ deps.append(f"fixture:{name}")
1341
+ return deps
1342
+
1343
+
1344
+ def _conftest_chain(path: str, facts_by_path: dict[str, ModuleFacts]) -> list[ModuleFacts]:
1345
+ """conftest.py files from the test's directory outward, nearest first."""
1346
+ chain: list[ModuleFacts] = []
1347
+ directory = PurePosixPath(path).parent
1348
+ while True:
1349
+ candidate = str(directory / "conftest.py") if str(directory) != "." else "conftest.py"
1350
+ if candidate in facts_by_path:
1351
+ chain.append(facts_by_path[candidate])
1352
+ if str(directory) == ".":
1353
+ break
1354
+ directory = directory.parent
1355
+ return chain
1356
+
1357
+
1358
+ def _is_unittest_class(cls: ast.ClassDef) -> bool:
1359
+ for base in cls.bases:
1360
+ parts, _ = decorator_chain(base)
1361
+ if parts and parts[-1].endswith("TestCase"):
1362
+ return True
1363
+ return False
1364
+
1365
+
1366
+ def _has_init(cls: ast.ClassDef) -> bool:
1367
+ return any(f.name == "__init__" for f in scope_functions(cls.body))
1368
+
1369
+
1370
+ def _normalise_testpath(entry: str) -> str:
1371
+ tp = entry.strip()
1372
+ while tp.startswith("./"):
1373
+ tp = tp[2:]
1374
+ return tp.strip("/")
1375
+
1376
+
1377
+ def _testpath_exists(entry: str, paths: set[str]) -> bool:
1378
+ """Whether a ``testpaths`` entry names a file or directory in the tree."""
1379
+ tp = _normalise_testpath(entry)
1380
+ if tp in ("", "."):
1381
+ return True
1382
+ if any(ch in tp for ch in "*?["):
1383
+ return any(
1384
+ fnmatch(p, tp) or any(fnmatch(str(parent), tp) for parent in PurePosixPath(p).parents)
1385
+ for p in paths
1386
+ )
1387
+ return any(p == tp or p.startswith(tp + "/") for p in paths)
1388
+
1389
+
1390
+ def _under_testpaths(path: str, testpaths: tuple[str, ...]) -> bool:
1391
+ """Whether ``path`` lies under one of pytest's ``testpaths`` entries.
1392
+
1393
+ Entries may be files, directories or globs (``tests/integ*``); a leading
1394
+ ``./`` is ignored.
1395
+ """
1396
+ if not testpaths:
1397
+ return True
1398
+ parents = [str(p) for p in PurePosixPath(path).parents if str(p) != "."]
1399
+ for raw in testpaths:
1400
+ tp = raw.strip()
1401
+ while tp.startswith("./"):
1402
+ tp = tp[2:]
1403
+ tp = tp.strip("/")
1404
+ if tp in ("", "."):
1405
+ return True
1406
+ if any(ch in tp for ch in "*?["):
1407
+ if fnmatch(path, tp) or any(fnmatch(parent, tp) for parent in parents):
1408
+ return True
1409
+ elif path == tp or path.startswith(tp + "/"):
1410
+ return True
1411
+ return False
1412
+
1413
+
1414
+ def _requested_names(trees: Iterable[ast.Module]) -> set[str]:
1415
+ """Every name a fixture could be requested by in these modules: function
1416
+ parameters, and identifier-like strings (``usefixtures("db")``,
1417
+ ``request.getfixturevalue("db")``). A superset, used only to skip
1418
+ bindings no test can ask for."""
1419
+ names: set[str] = set()
1420
+ for tree in trees:
1421
+ for node in ast.walk(tree):
1422
+ if isinstance(node, ast.arg):
1423
+ names.add(node.arg)
1424
+ elif isinstance(node, ast.Constant) and isinstance(node.value, str):
1425
+ if node.value.isidentifier():
1426
+ names.add(node.value)
1427
+ return names
1428
+
1429
+
1430
+ def _fixture_bound_to(
1431
+ module: str, attr: str, module_facts, seen: set[tuple[str, str]]
1432
+ ) -> tuple[Fixture, bool] | None:
1433
+ """The fixture ``module``'s namespace binds to ``attr``, with whether it
1434
+ names itself, following aliases, ``from`` imports and star imports (the
1435
+ last binding of a name wins; a fixture defined in the module wins over
1436
+ any binding)."""
1437
+ if (module, attr) in seen:
1438
+ return None
1439
+ seen.add((module, attr))
1440
+ facts = module_facts(module)
1441
+ if facts is None:
1442
+ return None
1443
+ if attr in facts.fixture_attrs:
1444
+ return facts.fixture_attrs[attr]
1445
+ for kind, bound, source, name in reversed(facts.bindings):
1446
+ if kind == "star":
1447
+ origin = module_facts(source)
1448
+ if origin is not None and attr in _star_names(origin.parsed.tree):
1449
+ return _fixture_bound_to(source, attr, module_facts, seen)
1450
+ elif bound == attr:
1451
+ return _fixture_bound_to(
1452
+ module if kind == "alias" else source, name, module_facts, seen
1453
+ )
1454
+ return None
1455
+
1456
+
1457
+ def _function_bound_to(
1458
+ module: str, attr: str, module_facts, seen: set[tuple[str, str]]
1459
+ ) -> str | None:
1460
+ """The function ``module``'s namespace binds to ``attr``, following
1461
+ aliases, ``from`` imports and star imports as _fixture_bound_to does;
1462
+ None when it is not a function defined in the source roots."""
1463
+ if (module, attr) in seen:
1464
+ return None
1465
+ seen.add((module, attr))
1466
+ facts = module_facts(module)
1467
+ if facts is None:
1468
+ return None
1469
+ if any(f.name == attr for f in scope_functions(facts.parsed.tree.body)):
1470
+ return facts.parsed.member_id(attr)
1471
+ for kind, bound, source, name in reversed(facts.bindings):
1472
+ if kind == "star":
1473
+ origin = module_facts(source)
1474
+ if origin is not None and attr in _star_names(origin.parsed.tree):
1475
+ return _function_bound_to(source, attr, module_facts, seen)
1476
+ elif bound == attr:
1477
+ return _function_bound_to(
1478
+ module if kind == "alias" else source, name, module_facts, seen
1479
+ )
1480
+ return None
1481
+
1482
+
1483
+ def _definition_bound_to(
1484
+ module: str, attr: str, module_facts, seen: set[tuple[str, str]]
1485
+ ) -> tuple[ModuleFacts, ast.AST] | str | None:
1486
+ """What ``module``'s namespace binds to ``attr``, following aliases,
1487
+ ``from`` imports and star imports: the defining module's facts and the
1488
+ ``def``/``class`` node when it is defined in the source roots; the
1489
+ module outside the roots it comes from (a string); or None (bound to
1490
+ something else, or not bound)."""
1491
+ if (module, attr) in seen:
1492
+ return None
1493
+ seen.add((module, attr))
1494
+ facts = module_facts(module)
1495
+ if facts is None:
1496
+ return module
1497
+ node = next(
1498
+ (n for n in facts.parsed.tree.body if isinstance(n, DEF_NODES) and n.name == attr), None
1499
+ )
1500
+ if node is not None:
1501
+ return facts, node
1502
+ for kind, bound, source, name in reversed(facts.bindings):
1503
+ if kind == "star":
1504
+ origin = module_facts(source)
1505
+ if origin is None or attr in _star_names(origin.parsed.tree):
1506
+ found = _definition_bound_to(source, attr, module_facts, seen)
1507
+ if found is not None:
1508
+ return found
1509
+ elif bound == attr:
1510
+ return _definition_bound_to(
1511
+ module if kind == "alias" else source, name, module_facts, seen
1512
+ )
1513
+ return None
1514
+
1515
+
1516
+ def _link_bound_hooks(facts: ModuleFacts, module_facts) -> None:
1517
+ """Hooks and xunit setup functions the module binds without defining
1518
+ them (``from tests.common import pytest_generate_tests``, ``setup_module
1519
+ = _setup``): pytest finds them in the namespace all the same."""
1520
+ defined = {f.name for f in scope_functions(facts.parsed.tree.body)}
1521
+ for kind, bound, _, _ in facts.bindings:
1522
+ if kind == "star" or bound in defined:
1523
+ continue
1524
+ if not (bound.startswith("pytest_") or bound in MODULE_SETUP_FUNCTIONS):
1525
+ continue
1526
+ symbol = _function_bound_to(facts.parsed.module, bound, module_facts, set())
1527
+ if symbol is None:
1528
+ continue
1529
+ if bound in MODULE_SETUP_FUNCTIONS:
1530
+ facts.setup_functions.append(symbol)
1531
+ else:
1532
+ facts.hooks.append(symbol)
1533
+ if bound not in PATH_SCOPED_HOOKS:
1534
+ facts.session_hooks.append(symbol)
1535
+ if bound in COLLECT_HOOKS and bound not in facts.collect_hooks:
1536
+ facts.collect_hooks.append(bound)
1537
+ # Star imports: any hook or setup name the origin offers.
1538
+ for kind, _, source, _ in facts.bindings:
1539
+ if kind != "star":
1540
+ continue
1541
+ origin = module_facts(source)
1542
+ for attr in _star_names(origin.parsed.tree) if origin is not None else ():
1543
+ if attr in defined or not (
1544
+ attr.startswith("pytest_") or attr in MODULE_SETUP_FUNCTIONS
1545
+ ):
1546
+ continue
1547
+ symbol = _function_bound_to(source, attr, module_facts, set())
1548
+ if symbol is None:
1549
+ continue
1550
+ if attr in MODULE_SETUP_FUNCTIONS:
1551
+ facts.setup_functions.append(symbol)
1552
+ else:
1553
+ facts.hooks.append(symbol)
1554
+ if attr not in PATH_SCOPED_HOOKS:
1555
+ facts.session_hooks.append(symbol)
1556
+
1557
+
1558
+ def _link_fixtures(facts: ModuleFacts, module_facts, requested: set[str]) -> None:
1559
+ """Register the fixtures this module binds to names of its own: pytest
1560
+ finds a fixture under every name bound to it in the module's namespace
1561
+ (``box2 = box``, ``from pkg.conftest import engine as motor``), except
1562
+ that a fixture with an explicit ``name=`` is found under that name only.
1563
+ Only names in ``requested`` are followed: any other cannot be asked for,
1564
+ and following it would parse every module a test imports from."""
1565
+ for kind, bound, source, _ in facts.bindings:
1566
+ if kind == "star":
1567
+ origin = module_facts(source)
1568
+ attrs = [n for n in _star_names(origin.parsed.tree) if n in requested] if origin else []
1569
+ else:
1570
+ attrs = [bound] if bound in requested else []
1571
+ for attr in attrs:
1572
+ if attr in facts.fixture_attrs:
1573
+ continue
1574
+ found = _fixture_bound_to(facts.parsed.module, attr, module_facts, set())
1575
+ if found is None:
1576
+ continue
1577
+ fixture, names_itself = found
1578
+ facts.fixture_attrs[attr] = found
1579
+ registered = fixture.name if names_itself else attr
1580
+ facts.fixtures.setdefault(registered, replace(fixture, name=registered))
1581
+
1582
+
1583
+ def _reexported_facts(facts: ModuleFacts, module_facts) -> list[ModuleFacts]:
1584
+ """Fixtures and hooks a plugin module exposes by importing them from
1585
+ submodules (``from .plugin import mocker``): pytest registers whatever the
1586
+ entry module's namespace holds, so follow one level of ``from`` imports,
1587
+ merged per submodule. Names are matched on what is imported (the original
1588
+ name, not an alias: a fixture keeps its own name); a hook counts only when
1589
+ it is imported too."""
1590
+ imported: dict[str, set[str] | None] = {} # submodule -> names, None for *
1591
+ for stmt in iter_scope_statements(facts.parsed.tree.body):
1592
+ if not isinstance(stmt, ast.ImportFrom):
1593
+ continue
1594
+ base = _absolute_module(facts.parsed, stmt)
1595
+ if not base:
1596
+ continue
1597
+ names = {a.name for a in stmt.names}
1598
+ if "*" in names:
1599
+ imported[base] = None
1600
+ elif (known := imported.setdefault(base, set())) is not None:
1601
+ known.update(names)
1602
+ out: list[ModuleFacts] = []
1603
+ for base, names in imported.items():
1604
+ sub = module_facts(base)
1605
+ if sub is None or sub is facts:
1606
+ continue
1607
+ if names is None:
1608
+ out.append(sub)
1609
+ continue
1610
+ visible = ModuleFacts(parsed=sub.parsed)
1611
+ visible.fixtures = {
1612
+ n: f
1613
+ for n, f in sub.fixtures.items()
1614
+ if n in names or f.symbol.rsplit(".", 1)[-1] in names
1615
+ }
1616
+ visible.hooks = [h for h in sub.hooks if h.rsplit(".", 1)[-1] in names]
1617
+ if visible.fixtures or visible.hooks:
1618
+ out.append(visible)
1619
+ return out
1620
+
1621
+
1622
+ # A module's scope for base-class lookups: (parsed module, its classes, imported
1623
+ # class names -> (module, name)).
1624
+ _Scope = tuple[Any, dict[str, ast.ClassDef], dict[str, tuple[str, str]]]
1625
+
1626
+
1627
+ def discover_pytest(
1628
+ snapshot: Snapshot, index: SourceIndex, options: DiscoveryOptions
1629
+ ) -> DiscoveryResult:
1630
+ result = DiscoveryResult(runner=RUNNER)
1631
+ config = read_pytest_config(snapshot, options.runner_args)
1632
+ for plugin in config["collecting_plugins"]:
1633
+ result.notes.append(
1634
+ DiscoveryNote(
1635
+ RUNNER,
1636
+ "plugin_collects_files",
1637
+ f"the configuration turns on {plugin}: what it collects is not a target",
1638
+ )
1639
+ )
1640
+ for arg in options.runner_args:
1641
+ option = arg.split("=", 1)[0]
1642
+ if option in UNMODELLED_COLLECTION_OPTIONS or (
1643
+ option.startswith("-o") and option != "-o" and not option.startswith("--")
1644
+ ):
1645
+ result.notes.append(
1646
+ DiscoveryNote(
1647
+ RUNNER,
1648
+ "unmodelled_runner_option",
1649
+ f"the run passes {arg!r} to pytest, which changes what it collects in a way "
1650
+ "discovery does not model",
1651
+ )
1652
+ )
1653
+ every_path = {*snapshot.python_paths, *snapshot.files, *snapshot.other_files}
1654
+ for arg in _unmodelled_addopts(tuple(config["ini_addopts"]), every_path):
1655
+ result.notes.append(
1656
+ DiscoveryNote(
1657
+ RUNNER,
1658
+ "unmodelled_runner_option",
1659
+ f"{config['source']}: addopts passes {arg!r} to pytest, which changes what it "
1660
+ "collects in a way discovery does not model",
1661
+ )
1662
+ )
1663
+ result.config = {k: (list(v) if isinstance(v, tuple) else v) for k, v in config.items()}
1664
+ python_files = tuple(config["python_files"])
1665
+ # ``testpaths`` as pytest uses it: entries that exist (when none does,
1666
+ # pytest collects from the rootdir), and files named there are collected
1667
+ # whatever ``python_files`` says (they are initial paths).
1668
+ testpaths = tuple(tp for tp in config["testpaths"] if _testpath_exists(tp, every_path))
1669
+ named_files = {_normalise_testpath(tp) for tp in testpaths if tp.endswith(".py")}
1670
+
1671
+ test_paths = [
1672
+ p
1673
+ for p in snapshot.files
1674
+ if (any(_matches_python_file(p, pat) for pat in python_files) or p in named_files)
1675
+ and _under_testpaths(p, testpaths)
1676
+ ]
1677
+ for path in snapshot.python_paths:
1678
+ if (
1679
+ path not in snapshot.files
1680
+ and any(_matches_python_file(path, pat) for pat in python_files)
1681
+ and _under_testpaths(path, testpaths)
1682
+ and _collected_dir(path, tuple(config["norecursedirs"]))
1683
+ ):
1684
+ result.notes.append(
1685
+ DiscoveryNote(
1686
+ RUNNER,
1687
+ "test_file_outside_roots",
1688
+ f"{path}: pytest collects it, but it is outside the source roots, so its "
1689
+ "tests are not targets; add a source root that contains it (for a src "
1690
+ "layout: --source-root src --source-root .)",
1691
+ path,
1692
+ )
1693
+ )
1694
+ conftest_paths = [p for p in snapshot.files if PurePosixPath(p).name == "conftest.py"]
1695
+ # A conftest outside the source roots is not read: its fixtures, hooks
1696
+ # and import-time code are invisible, so every test under it depends on
1697
+ # it as an unknown (selected, with this note saying why).
1698
+ outside_conftests = sorted(
1699
+ p
1700
+ for p in snapshot.python_paths
1701
+ if PurePosixPath(p).name == "conftest.py"
1702
+ and p not in snapshot.files
1703
+ and _collected_dir(p, tuple(config["norecursedirs"]))
1704
+ )
1705
+
1706
+ def outside_conftests_of(path: str) -> list[str]:
1707
+ """Unknown dependencies on the conftests outside the roots that
1708
+ apply to ``path``."""
1709
+ directories = {str(d) for d in PurePosixPath(path).parents}
1710
+ return [
1711
+ f"conftest:{c}"
1712
+ for c in outside_conftests
1713
+ if str(PurePosixPath(c).parent) in directories
1714
+ ]
1715
+
1716
+ for path in outside_conftests:
1717
+ result.notes.append(
1718
+ DiscoveryNote(
1719
+ RUNNER,
1720
+ "conftest_outside_roots",
1721
+ f"{path}: pytest loads it, but it is outside the source roots, so what it "
1722
+ "defines is unknown and every test under it is always selected; add a source "
1723
+ "root that contains it (for a src layout: --source-root src --source-root .)",
1724
+ path,
1725
+ )
1726
+ )
1727
+ parsed, failed = parse_modules(snapshot, sorted(set(test_paths + conftest_paths)))
1728
+ for path in failed:
1729
+ # Two reasons, and they need different answers: a file that does not
1730
+ # parse is also an analysis error (the plan degrades and selects
1731
+ # everything), while one that cannot be named is dropped silently --
1732
+ # pytest imports it by its basename, so its tests are collected and
1733
+ # are not targets (pytest-asyncio's ``docs/how-to-guides``).
1734
+ nameable = module_name_for(path, snapshot.source_roots) is not None
1735
+ detail = (
1736
+ f"{path}: did not parse"
1737
+ if nameable
1738
+ else (
1739
+ f"{path}: cannot be named from any source root (a directory component is not a "
1740
+ "Python identifier), so pytest collects it and it is not a target; name it with "
1741
+ "a DIR=PREFIX source root"
1742
+ )
1743
+ )
1744
+ result.notes.append(DiscoveryNote(RUNNER, "unparsed_file", detail, path))
1745
+ facts_by_path = {pm.path: _collect_facts(pm) for pm in parsed}
1746
+ facts_by_module = {f.parsed.module: f for f in facts_by_path.values()}
1747
+
1748
+ def module_facts(name: str) -> ModuleFacts | None:
1749
+ if name in facts_by_module:
1750
+ return facts_by_module[name]
1751
+ if name in index.modules:
1752
+ pm, _ = parse_modules(snapshot, [index.symbols[name].path])
1753
+ if pm:
1754
+ facts_by_module[name] = _collect_facts(pm[0])
1755
+ return facts_by_module[name]
1756
+ return None
1757
+
1758
+ # Names bound to fixtures defined elsewhere. Plugin modules are linked
1759
+ # where plugin_facts finds them, with their own requests added.
1760
+ requested = _requested_names(f.parsed.tree for f in facts_by_path.values())
1761
+ linked: set[str] = set()
1762
+
1763
+ def link(facts: ModuleFacts) -> ModuleFacts:
1764
+ if facts.parsed.module not in linked:
1765
+ linked.add(facts.parsed.module)
1766
+ requested.update(_requested_names([facts.parsed.tree]))
1767
+ _link_fixtures(facts, module_facts, requested)
1768
+ _link_bound_hooks(facts, module_facts)
1769
+ return facts
1770
+
1771
+ for facts in list(facts_by_path.values()):
1772
+ link(facts)
1773
+
1774
+ def plugin_facts(
1775
+ declared: list[str],
1776
+ where: str,
1777
+ kind: str = "pytest_plugins",
1778
+ seen: set[str] | None = None,
1779
+ ) -> list[ModuleFacts]:
1780
+ """The plugin modules ``declared`` loads, and those they declare in
1781
+ turn: pytest registers each module plugin, then its own
1782
+ ``pytest_plugins`` (poetry's conftest loads a package whose
1783
+ ``__init__`` lists the modules holding its autouse fixtures)."""
1784
+ seen = set() if seen is None else seen
1785
+ found: list[ModuleFacts] = []
1786
+ for name in declared:
1787
+ # pytest's own plugins are builtin unless this repository *is*
1788
+ # pytest and ships them under _pytest.
1789
+ if name not in index.modules and f"_pytest.{name}" in index.modules:
1790
+ name = f"_pytest.{name}"
1791
+ if name.split(".")[0] in BUILTIN_PLUGINS and name not in index.modules:
1792
+ continue
1793
+ facts = module_facts(name)
1794
+ if facts is None:
1795
+ result.notes.append(
1796
+ DiscoveryNote(
1797
+ RUNNER,
1798
+ "plugin_out_of_scope",
1799
+ f"{where}: {kind} entry {name!r} is not within the source roots",
1800
+ )
1801
+ )
1802
+ continue
1803
+ if facts.parsed.module in seen:
1804
+ continue
1805
+ seen.add(facts.parsed.module)
1806
+ found.append(link(facts))
1807
+ found.extend(link(sub) for sub in _reexported_facts(facts, module_facts))
1808
+ if facts.plugins:
1809
+ found.extend(plugin_facts(facts.plugins, facts.parsed.path, seen=seen))
1810
+ return found
1811
+
1812
+ global_plugins: list[ModuleFacts] = []
1813
+ if config["entry_point_plugins"]:
1814
+ global_plugins.extend(
1815
+ plugin_facts(list(config["entry_point_plugins"]), "pyproject/setup.cfg", "pytest11")
1816
+ )
1817
+ if config["addopts_plugins"]:
1818
+ global_plugins.extend(plugin_facts(list(config["addopts_plugins"]), "addopts", "-p"))
1819
+ # ``pytest_plugins`` in a conftest or a test module registers the plugin
1820
+ # for the session once pytest imports the file, so its fixtures and
1821
+ # hooks reach every test, not only those beside the declaration.
1822
+ plugins_seen: set[str] = set()
1823
+ for path in sorted(conftest_paths) + sorted(test_paths):
1824
+ facts = facts_by_path.get(path)
1825
+ if facts is not None and facts.plugins:
1826
+ global_plugins.extend(plugin_facts(facts.plugins, path, seen=plugins_seen))
1827
+ plugin_hooks = [h for p in global_plugins for h in p.hooks]
1828
+ ini_usefixtures = tuple(config["usefixtures"])
1829
+ # Hooks of conftests off a test's path that pytest calls for the whole
1830
+ # session (``pytest_collection_modifyitems`` in ``tests/a/conftest.py``
1831
+ # sees, and may reorder, skip or mark, the items of ``tests/b``).
1832
+ plugin_hooks += [
1833
+ h
1834
+ for path in sorted(conftest_paths)
1835
+ if path in facts_by_path
1836
+ for h in facts_by_path[path].session_hooks
1837
+ ]
1838
+
1839
+ unresolved: Counter[str] = Counter()
1840
+ assumed: Counter[str] = Counter()
1841
+ # Filled while walking every module: class symbols followed as a base,
1842
+ # and (symbol, detail) for each class pytest's rules skipped.
1843
+ used_as_base: set[str] = set()
1844
+ uncollected: list[tuple[str, str]] = []
1845
+ for path in sorted(test_paths):
1846
+ facts = facts_by_path.get(path)
1847
+ if facts is None:
1848
+ continue
1849
+ conftests = _conftest_chain(path, facts_by_path)
1850
+ resolver = _Resolver(
1851
+ facts, conftests, global_plugins, options, unresolved, assumed, ini_usefixtures
1852
+ )
1853
+ module_deps = [facts.parsed.module]
1854
+ module_deps += outside_conftests_of(path)
1855
+ module_deps += [c.parsed.module for c in conftests]
1856
+ for c in conftests:
1857
+ module_deps += c.hooks
1858
+ module_deps += facts.hooks # e.g. pytest_generate_tests in the test module
1859
+ module_deps += plugin_hooks # hooks of the project's own pytest plugins
1860
+ module_deps += facts.setup_functions
1861
+ for owner in (facts, *conftests):
1862
+ for name in MODULE_LEVEL_PYTEST_NAMES:
1863
+ symbol = owner.parsed.member_id(name)
1864
+ if symbol in index.symbols:
1865
+ module_deps.append(symbol)
1866
+ _collect_module_tests(
1867
+ result,
1868
+ facts,
1869
+ resolver,
1870
+ config,
1871
+ module_deps,
1872
+ index,
1873
+ module_facts,
1874
+ used_as_base,
1875
+ uncollected,
1876
+ )
1877
+
1878
+ # A class pytest's own rules skip is only worth reporting if nothing
1879
+ # collected it through inheritance either, which is known once every
1880
+ # module has been walked (networkx's ``BaseGraphTester`` is a base in
1881
+ # another module, alembic's ``BatchApplyTest`` is a base nowhere).
1882
+ for symbol, detail in uncollected:
1883
+ if symbol not in used_as_base:
1884
+ result.notes.append(
1885
+ DiscoveryNote(RUNNER, "uncollected_test_class", detail, detail.split("::", 1)[0])
1886
+ )
1887
+
1888
+ # Collection hooks of conftests and test modules, and of the plugin
1889
+ # modules the session loads.
1890
+ hook_owners = {f.parsed.path: f for f in facts_by_path.values()}
1891
+ for plugin in global_plugins:
1892
+ hook_owners.setdefault(plugin.parsed.path, plugin)
1893
+ for _, facts in sorted(hook_owners.items()):
1894
+ for hook in facts.collect_hooks:
1895
+ result.notes.append(
1896
+ DiscoveryNote(
1897
+ RUNNER,
1898
+ "plugin_collects_files",
1899
+ f"{facts.parsed.path}: binds {hook}, which makes tests out of files or "
1900
+ "objects by its own rules; what it collects is not a target",
1901
+ facts.parsed.path,
1902
+ )
1903
+ )
1904
+
1905
+ def doctest_deps(pm: ParsedModule, conftests: list[ModuleFacts]) -> list[str]:
1906
+ """What a doctest item runs besides its examples: pytest picks up the
1907
+ module's and the conftests' autouse fixtures for it, provides
1908
+ ``doctest_namespace`` (which an autouse fixture usually fills, as
1909
+ pandas's root conftest does), applies the ini ``usefixtures``, and
1910
+ calls the session-wide hooks."""
1911
+ facts = facts_by_path.get(pm.path) or link(_collect_facts(pm))
1912
+ resolver = _Resolver(
1913
+ facts, conftests, global_plugins, options, unresolved, assumed, ini_usefixtures
1914
+ )
1915
+ return (
1916
+ plugin_hooks
1917
+ + outside_conftests_of(pm.path)
1918
+ + resolver.lifecycle([], ["doctest_namespace"])
1919
+ )
1920
+
1921
+ _collect_doctests(
1922
+ result, snapshot, index, {**config, "testpaths": testpaths}, facts_by_path, doctest_deps
1923
+ )
1924
+
1925
+ for name, count in sorted(unresolved.items()):
1926
+ result.notes.append(
1927
+ DiscoveryNote(
1928
+ RUNNER,
1929
+ "unresolved_fixture",
1930
+ f"fixture {name!r} requested by {count} test(s) was not found in the source "
1931
+ "roots; its users are selected conservatively (declare it external to opt out)",
1932
+ )
1933
+ )
1934
+ for name, count in sorted(assumed.items()):
1935
+ origin = (
1936
+ "declared external"
1937
+ if name in options.external_fixtures
1938
+ else f"assumed from the installed plugin {WELL_KNOWN_PLUGIN_FIXTURES[name]}"
1939
+ )
1940
+ result.notes.append(
1941
+ DiscoveryNote(
1942
+ RUNNER,
1943
+ "external_fixture",
1944
+ f"fixture {name!r} requested by {count} test(s) is not in the source roots and "
1945
+ f"was {origin}; it is not a dependency of its users",
1946
+ )
1947
+ )
1948
+ result.targets.sort()
1949
+ return result
1950
+
1951
+
1952
+ def _collect_doctests(
1953
+ result: DiscoveryResult,
1954
+ snapshot: Snapshot,
1955
+ index: SourceIndex,
1956
+ config: dict[str, Any],
1957
+ facts_by_path: dict[str, ModuleFacts],
1958
+ fixture_deps: Callable[[ParsedModule, list[ModuleFacts]], list[str]] | None = None,
1959
+ ) -> None:
1960
+ """Doctest targets (see the module docstring)."""
1961
+ testpaths = tuple(config["testpaths"])
1962
+ norecurse = tuple(config["norecursedirs"])
1963
+ parser = doctest.DocTestParser()
1964
+ globs = tuple(config["doctest_globs"])
1965
+ for path in sorted(snapshot.other_files):
1966
+ # A file a glob matches but the snapshot does not read (only .txt,
1967
+ # .rst and .md are read) holds doctests that are not targets.
1968
+ if (
1969
+ path not in snapshot.text_files
1970
+ and any(fnmatch(PurePosixPath(path).name, g) for g in globs)
1971
+ and _under_testpaths(path, testpaths)
1972
+ and _collected_dir(path, norecurse)
1973
+ ):
1974
+ result.notes.append(
1975
+ DiscoveryNote(
1976
+ RUNNER,
1977
+ "unparsed_file",
1978
+ f"{path}: a --doctest-glob matches it, but only .txt, .rst and .md files "
1979
+ "are read for doctests, so its examples are not targets",
1980
+ path,
1981
+ )
1982
+ )
1983
+ for path, content in sorted(snapshot.text_files.items()):
1984
+ name = PurePosixPath(path).name
1985
+ if not (
1986
+ _under_testpaths(path, testpaths)
1987
+ and _collected_dir(path, norecurse)
1988
+ and any(fnmatch(name, g) for g in config["doctest_globs"])
1989
+ ):
1990
+ continue
1991
+ try:
1992
+ has_examples = bool(parser.get_examples(content.decode("utf-8", "replace")))
1993
+ except ValueError:
1994
+ has_examples = True # pytest reports a malformed file as a failing item
1995
+ if has_examples:
1996
+ # Not Python: a change to it is invisible, so it is always selected.
1997
+ result.targets.append(Target(RUNNER, f"{path}::{name}", f"doctest-file:{path}", ()))
1998
+ if not config["doctest_modules"]:
1999
+ return
2000
+ paths = [
2001
+ p
2002
+ for p in snapshot.files
2003
+ if _under_testpaths(p, testpaths)
2004
+ and _collected_dir(p, norecurse)
2005
+ and PurePosixPath(p).name not in ("setup.py", "__main__.py")
2006
+ ]
2007
+ for path in snapshot.python_paths:
2008
+ if (
2009
+ path not in snapshot.files
2010
+ and _under_testpaths(path, testpaths)
2011
+ and _collected_dir(path, norecurse)
2012
+ and PurePosixPath(path).name not in ("setup.py", "__main__.py", "conftest.py")
2013
+ ):
2014
+ result.notes.append(
2015
+ DiscoveryNote(
2016
+ RUNNER,
2017
+ "test_file_outside_roots",
2018
+ f"{path}: --doctest-modules collects its docstrings, but it is outside the "
2019
+ "source roots, so they are not targets; add a source root that contains it",
2020
+ path,
2021
+ )
2022
+ )
2023
+ parsed, failed = parse_modules(snapshot, sorted(paths))
2024
+ for path in failed:
2025
+ result.notes.append(
2026
+ DiscoveryNote(
2027
+ RUNNER,
2028
+ "unparsed_file",
2029
+ f"{path}: --doctest-modules collects its docstrings, but it did not parse or "
2030
+ "cannot be named from any source root, so they are not targets",
2031
+ path,
2032
+ )
2033
+ )
2034
+ every_path = {*snapshot.python_paths, *snapshot.files}
2035
+ for pm in parsed:
2036
+ if _assigns_name(pm.tree.body, "__test__"):
2037
+ result.notes.append(
2038
+ DiscoveryNote(
2039
+ RUNNER,
2040
+ "unmodelled_test_binding",
2041
+ f"{pm.path}: defines __test__, whose doctests pytest collects; they are "
2042
+ "not targets",
2043
+ pm.path,
2044
+ )
2045
+ )
2046
+ # The name pytest gives the module (doctest items are named after it):
2047
+ # by its packages in the default import modes, from the rootdir with
2048
+ # importlib.
2049
+ module_name = (
2050
+ pm.module
2051
+ if config["import_mode"] == "importlib"
2052
+ else _package_module_name(pm.path, every_path)
2053
+ )
2054
+ conftests = _conftest_chain(pm.path, facts_by_path)
2055
+ base_deps = [c.parsed.module for c in conftests] + [h for c in conftests for h in c.hooks]
2056
+ fixtures_added = False
2057
+ for qualname, symbol, docstring in _docstrings(pm):
2058
+ try:
2059
+ examples = parser.get_examples(docstring)
2060
+ except ValueError:
2061
+ examples = None # pytest reports it as a failing item
2062
+ if examples == []:
2063
+ continue
2064
+ if not fixtures_added:
2065
+ # Once per module, and only for one with examples.
2066
+ fixtures_added = True
2067
+ if fixture_deps is not None:
2068
+ base_deps += fixture_deps(pm, conftests)
2069
+ deps = [*base_deps, f"dynamic:{pm.module}"]
2070
+ if examples is None:
2071
+ deps.append("doctest:unparsed")
2072
+ else:
2073
+ for module in _example_imports(examples):
2074
+ if module is None:
2075
+ deps.append("doctest:unparsed")
2076
+ elif module in index.modules:
2077
+ deps.append(f"dynamic:{module}")
2078
+ name = module_name if not qualname else f"{module_name}.{qualname}"
2079
+ result.targets.append(
2080
+ Target(RUNNER, f"{pm.path}::{name}", symbol, tuple(sorted(set(deps))))
2081
+ )
2082
+
2083
+
2084
+ def _assigns_name(body: list[ast.stmt], name: str) -> bool:
2085
+ return any(
2086
+ isinstance(stmt, (ast.Assign, ast.AnnAssign))
2087
+ and any(
2088
+ isinstance(t, ast.Name) and t.id == name
2089
+ for t in (stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target])
2090
+ )
2091
+ for stmt in iter_scope_statements(body)
2092
+ )
2093
+
2094
+
2095
+ def _package_module_name(path: str, paths: set[str]) -> str:
2096
+ """The name pytest's ``prepend``/``append`` import modes give a module:
2097
+ its directories count while each holds an ``__init__.py``."""
2098
+ pure = PurePosixPath(path)
2099
+ parts = [] if pure.name == "__init__.py" else [pure.stem]
2100
+ directory = pure.parent
2101
+ while str(directory) not in (".", "") and str(directory / "__init__.py") in paths:
2102
+ parts.insert(0, directory.name)
2103
+ directory = directory.parent
2104
+ return ".".join(parts) or pure.parent.name
2105
+
2106
+
2107
+ def _docstrings(pm: ParsedModule) -> list[tuple[str, str, str]]:
2108
+ """(qualified name, symbol id, docstring) of every object doctest's finder
2109
+ visits: the module, its functions and classes, and recursively their
2110
+ methods and nested classes."""
2111
+ found: list[tuple[str, str, str]] = []
2112
+ doc = ast.get_docstring(pm.tree, clean=False)
2113
+ if doc:
2114
+ found.append(("", pm.module, doc))
2115
+
2116
+ def walk(body: list[ast.stmt], prefix: str, container: str | None) -> None:
2117
+ # Definitions under ``if``/``try`` are attributes like any other.
2118
+ for node in iter_scope_statements(body):
2119
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
2120
+ continue
2121
+ qual = f"{prefix}.{node.name}" if prefix else node.name
2122
+ symbol = f"{container}.{node.name}" if container else pm.member_id(node.name)
2123
+ doc = ast.get_docstring(node, clean=False)
2124
+ if doc:
2125
+ found.append((qual, symbol, doc))
2126
+ if isinstance(node, ast.ClassDef):
2127
+ walk(node.body, qual, symbol)
2128
+
2129
+ walk(pm.tree.body, "", None)
2130
+ return found
2131
+
2132
+
2133
+ def _example_imports(examples: list[doctest.Example]) -> list[str | None]:
2134
+ """Absolute modules the examples import; None for an example that does
2135
+ not parse (what it uses is unknown)."""
2136
+ modules: list[str | None] = []
2137
+ for example in examples:
2138
+ try:
2139
+ tree = ast.parse(example.source)
2140
+ except SyntaxError:
2141
+ modules.append(None)
2142
+ continue
2143
+ for node in ast.walk(tree):
2144
+ if isinstance(node, ast.Import):
2145
+ modules += [a.name for a in node.names]
2146
+ elif isinstance(node, ast.ImportFrom) and node.module and not node.level:
2147
+ modules.append(node.module)
2148
+ modules += [f"{node.module}.{a.name}" for a in node.names]
2149
+ return modules
2150
+
2151
+
2152
+ def _collect_module_tests(
2153
+ result: DiscoveryResult,
2154
+ facts: ModuleFacts,
2155
+ resolver: _Resolver,
2156
+ config: dict[str, Any],
2157
+ module_deps: list[str],
2158
+ index: SourceIndex,
2159
+ module_facts: Any = None,
2160
+ used_as_base: set[str] | None = None,
2161
+ uncollected: list[tuple[str, str]] | None = None,
2162
+ ) -> None:
2163
+ parsed = facts.parsed
2164
+ functions = tuple(config["python_functions"])
2165
+ classes = tuple(config["python_classes"])
2166
+ module_classes = {c.name: c for c in scope_classes(parsed.tree.body)}
2167
+ # Names used as a base anywhere in the module: such a class contributes
2168
+ # its methods through its subclasses, so it is not an uncollected class.
2169
+ base_names = {
2170
+ (decorator_chain(base)[0] or [""])[-1]
2171
+ for node in ast.walk(parsed.tree)
2172
+ if isinstance(node, ast.ClassDef)
2173
+ for base in node.bases
2174
+ }
2175
+
2176
+ def add(
2177
+ nodeid: str,
2178
+ entry: str,
2179
+ class_levels: list[dict[str, Fixture]],
2180
+ requests: list[str],
2181
+ extra: list[str],
2182
+ marks: Marks,
2183
+ ):
2184
+ if entry not in index.symbols:
2185
+ result.notes.append(
2186
+ DiscoveryNote(RUNNER, "missing_symbol", f"{nodeid}: {entry} is not in the index")
2187
+ )
2188
+ deps = module_deps + extra + resolver.lifecycle(class_levels, requests, marks.supplied)
2189
+ result.targets.append(Target(RUNNER, nodeid, entry, tuple(sorted(set(deps)))))
2190
+
2191
+ # Module scopes reached through base classes: name -> (parsed, classes,
2192
+ # imported class names). None for a module outside the source roots.
2193
+ scopes: dict[str, _Scope | None] = {}
2194
+ # Per module: alias -> module it names, for a dotted base ``alias.Class``.
2195
+ module_prefixes: dict[str, dict[str, str]] = {}
2196
+
2197
+ def scope_for(module: str) -> _Scope | None:
2198
+ """None for a module outside the source roots, or one whose scope is
2199
+ being built (an import cycle)."""
2200
+ if module in scopes:
2201
+ return scopes[module]
2202
+ scopes[module] = None # guards import cycles while this one is built
2203
+ facts = module_facts(module) if module_facts is not None else None
2204
+ if facts is None:
2205
+ return None
2206
+ modules: dict[str, str] = {}
2207
+ classes = {c.name: c for c in scope_classes(facts.parsed.tree.body)}
2208
+ imported: dict[str, tuple[str, str]] = {}
2209
+ for stmt in iter_scope_statements(facts.parsed.tree.body):
2210
+ if isinstance(stmt, ast.Import):
2211
+ # ``import tests.queues as t``: ``t.LifoDiskQueueTest`` is that
2212
+ # module's class, recorded under the alias as a module prefix.
2213
+ for alias in stmt.names:
2214
+ modules[alias.asname or alias.name.split(".")[0]] = (
2215
+ alias.name if alias.asname else alias.name.split(".")[0]
2216
+ )
2217
+ continue
2218
+ if not isinstance(stmt, ast.ImportFrom):
2219
+ continue
2220
+ src = _absolute_module(facts.parsed, stmt)
2221
+ for alias in stmt.names:
2222
+ if alias.name == "*":
2223
+ sub_scope = scope_for(src)
2224
+ for name in sub_scope[1] if sub_scope else ():
2225
+ imported.setdefault(name, (src, name))
2226
+ else:
2227
+ imported[alias.asname or alias.name] = (src, alias.name)
2228
+ modules.setdefault(alias.asname or alias.name, f"{src}.{alias.name}")
2229
+ module_prefixes[module] = modules
2230
+ scope = (facts.parsed, classes, imported)
2231
+ scopes[module] = scope
2232
+ return scope
2233
+
2234
+ own_scope = (parsed, module_classes, (scope_for(parsed.module) or (None, {}, {}))[2])
2235
+ # Base class id -> the module defining it, whose names its decorators use.
2236
+ defined_in: dict[str, str] = {}
2237
+ stored: dict[tuple[str, str], list[ast.expr]] = {}
2238
+
2239
+ def stored_mark(module: str, name: str) -> list[ast.expr]:
2240
+ """The mark expressions ``module`` binds to ``name`` at module level
2241
+ (``skip_pyarrow = pytest.mark.usefixtures("pyarrow_skip")``, or a
2242
+ list of marks), following aliases and ``from`` imports; empty when
2243
+ it binds none that can be seen. Only names used as marks are
2244
+ followed, so no imported library is parsed for this."""
2245
+ key = (module, name)
2246
+ if key in stored:
2247
+ return stored[key]
2248
+ stored[key] = [] # guards cycles
2249
+ scope = scope_for(module)
2250
+ if scope is None:
2251
+ return []
2252
+ found: list[ast.expr] = []
2253
+ values = [v for n, v in scope_assignments(scope[0].tree.body) if n == name]
2254
+ if values:
2255
+ # Every binding counts: which one is live is not tracked.
2256
+ expand = expander(module)
2257
+ for value in values:
2258
+ elts = value.elts if isinstance(value, (ast.List, ast.Tuple)) else [value]
2259
+ for expr in elts:
2260
+ found += expand(expr) or [expr]
2261
+ elif name in scope[2]:
2262
+ found = stored_mark(*scope[2][name])
2263
+ stored[key] = found
2264
+ return found
2265
+
2266
+ def expander(module: str) -> MarkExpander:
2267
+ """Resolves ``name`` and ``alias.name`` mark entries in ``module``."""
2268
+
2269
+ def expand(expr: ast.expr) -> list[ast.expr] | None:
2270
+ if isinstance(expr, ast.Name):
2271
+ return stored_mark(module, expr.id)
2272
+ parts, call = decorator_chain(expr)
2273
+ if call is None and len(parts) == 2:
2274
+ target = module_prefixes.get(module, {}).get(parts[0])
2275
+ if target is not None:
2276
+ return stored_mark(target, parts[1])
2277
+ return None
2278
+
2279
+ return expand
2280
+
2281
+ own_marks = expander(parsed.module)
2282
+ module_marks = _marks_from_pytestmark(parsed.tree.body, own_marks)
2283
+
2284
+ def class_in(module: str, name: str) -> tuple[ast.ClassDef, str, Any] | None:
2285
+ """The class ``name`` names in ``module``: defined there, or imported
2286
+ there from a module in the source roots (pandas's
2287
+ ``tests/extension/base/__init__.py`` re-exports the classes of its
2288
+ submodules), followed to the module that defines it."""
2289
+ visited: set[tuple[str, str]] = set()
2290
+ scope = scope_for(module)
2291
+ while scope is not None and (module, name) not in visited:
2292
+ visited.add((module, name))
2293
+ if name in scope[1]:
2294
+ return scope[1][name], scope[0].member_id(name), scope
2295
+ if name not in scope[2]:
2296
+ return None
2297
+ module, name = scope[2][name]
2298
+ scope = scope_for(module)
2299
+ return None
2300
+
2301
+ def mro(
2302
+ cls: ast.ClassDef,
2303
+ nodeid: str,
2304
+ quiet: bool = False,
2305
+ scope: tuple[Any, dict[str, ast.ClassDef], dict[str, tuple[str, str]]] | None = None,
2306
+ ) -> list[tuple[ast.ClassDef, str]]:
2307
+ """Base classes, nearest first, with their symbol ids. A base defined
2308
+ in another module in the source roots is followed too (networkx's
2309
+ ``TestDiGraph(BaseGraphTester)``), and its own bases resolve in the
2310
+ module that defines it, not in this one."""
2311
+ chain: list[tuple[ast.ClassDef, str]] = []
2312
+ seen: set[tuple[str, str]] = set()
2313
+ start = scope or own_scope
2314
+ queue = [(base, start) for base in cls.bases]
2315
+ while queue:
2316
+ base, (owner, classes_here, imports_here) = queue.pop(0)
2317
+ parts, _ = decorator_chain(base)
2318
+ name = parts[-1] if parts else ""
2319
+ if name in ("object", "") or (owner.module, name) in seen:
2320
+ continue
2321
+ seen.add((owner.module, name))
2322
+ found = None
2323
+ if name in classes_here and not (owner.module == start[0].module and name == cls.name):
2324
+ found = (
2325
+ classes_here[name],
2326
+ owner.member_id(name),
2327
+ (owner, classes_here, imports_here),
2328
+ )
2329
+ elif name in imports_here:
2330
+ found = class_in(*imports_here[name])
2331
+ elif len(parts) > 1:
2332
+ # ``t.LifoDiskQueueTest``: the prefix names a module.
2333
+ prefixes = module_prefixes.get(owner.module, {})
2334
+ head = ".".join(parts[:-1])
2335
+ source = prefixes.get(parts[0], parts[0])
2336
+ if len(parts) > 2:
2337
+ source = f"{source}.{'.'.join(parts[1:-1])}" if parts[0] in prefixes else head
2338
+ found = class_in(source, name)
2339
+ if found is not None:
2340
+ base_cls, base_id, base_scope = found
2341
+ if used_as_base is not None:
2342
+ used_as_base.add(base_id)
2343
+ defined_in[base_id] = base_scope[0].module
2344
+ chain.append((base_cls, base_id))
2345
+ queue.extend((b, base_scope) for b in base_cls.bases)
2346
+ elif not (
2347
+ quiet
2348
+ or name in NO_TEST_BASES
2349
+ or (
2350
+ name.endswith("TestCase")
2351
+ and _framework_base(name, parts, imports_here, module_prefixes, owner)
2352
+ )
2353
+ ):
2354
+ result.notes.append(
2355
+ DiscoveryNote(
2356
+ RUNNER,
2357
+ "unknown_base_class",
2358
+ f"{nodeid}: base class {name!r} is not defined in this module or "
2359
+ "imported from one in the source roots; test methods it may "
2360
+ "contribute are not discovered",
2361
+ parsed.path,
2362
+ )
2363
+ )
2364
+ return chain
2365
+
2366
+ module_funcs = {f.name: f for f in scope_functions(parsed.tree.body)}
2367
+
2368
+ def unmodelled(nodeid: str, value: str) -> None:
2369
+ result.notes.append(
2370
+ DiscoveryNote(
2371
+ RUNNER,
2372
+ "unmodelled_test_binding",
2373
+ f"{nodeid} is bound to {value}, which discovery cannot follow; pytest "
2374
+ "collects it if it is a test function or class, and it is not a target",
2375
+ parsed.path,
2376
+ )
2377
+ )
2378
+
2379
+ def walk_class(
2380
+ cls: ast.ClassDef,
2381
+ prefix_ids: list[str],
2382
+ nodeid_prefix: str,
2383
+ inherited: Marks,
2384
+ outer: list[dict[str, Fixture]] | None = None,
2385
+ name: str | None = None,
2386
+ class_id: str | None = None,
2387
+ scope: _Scope | None = None,
2388
+ imported: bool = False,
2389
+ ) -> None:
2390
+ """Targets of a class collected here as ``name`` (its own name, or the
2391
+ one a test module imports it under, with ``class_id`` and ``scope``
2392
+ where it is defined); ``outer`` are the enclosing classes' fixture
2393
+ levels, innermost first. An ``imported`` class pytest's rules skip
2394
+ is not reported: it is collected where it is defined, if anywhere."""
2395
+ name = name or cls.name
2396
+ # pytest's unittest plugin collects a TestCase subclass whatever it is
2397
+ # called, and the base that brings TestCase in may be several classes
2398
+ # and modules away (DRF's ``XffSpoofingTests(XffTestingBase)``).
2399
+ unittest_style = _is_unittest_class(cls) or any(
2400
+ _is_unittest_class(base) for base, _ in mro(cls, nodeid_prefix, quiet=True, scope=scope)
2401
+ )
2402
+ # A class with ``__init__`` is skipped, except a TestCase: the
2403
+ # unittest plugin collects it all the same.
2404
+ if not (unittest_style or _matches(classes, name)) or (
2405
+ _has_init(cls) and not unittest_style
2406
+ ):
2407
+ # A class pytest's own rules skip, but that defines test methods,
2408
+ # is a class some plugin collects (SQLAlchemy's testing plugin
2409
+ # collects ``<Name>Test``): report it rather than guess either way.
2410
+ if (
2411
+ not imported
2412
+ and cls.name not in base_names
2413
+ and not _has_init(cls)
2414
+ and any(
2415
+ _matches(functions, f.name) and not _is_fixture(f)[0]
2416
+ for f in scope_functions(cls.body)
2417
+ )
2418
+ ):
2419
+ symbol = (
2420
+ f"{prefix_ids[-1]}.{cls.name}" if prefix_ids else parsed.member_id(cls.name)
2421
+ )
2422
+ detail = (
2423
+ f"{nodeid_prefix}::{cls.name}: defines test methods but does not "
2424
+ "match python_classes; a pytest plugin may collect it, and what it "
2425
+ "collects is not a target"
2426
+ )
2427
+ if uncollected is not None:
2428
+ uncollected.append((symbol, detail))
2429
+ return
2430
+ if class_id is None:
2431
+ class_id = f"{prefix_ids[-1]}.{cls.name}" if prefix_ids else parsed.member_id(cls.name)
2432
+ nodeid = f"{nodeid_prefix}::{name}"
2433
+ bases = mro(cls, nodeid, scope=scope)
2434
+ # Fixture lookup: this class with what it inherits, then outer classes.
2435
+ class_levels = [_class_level([(cls, class_id), *bases]), *(outer or [])]
2436
+ # Marks: the enclosing scopes', the bases' (pytest reads marks along
2437
+ # the MRO), then the class's own.
2438
+ home = (scope or own_scope)[0].module
2439
+ class_marks = inherited
2440
+ for owner, owner_id in [*reversed(bases), (cls, class_id)]:
2441
+ expand = expander(defined_in.get(owner_id, home))
2442
+ class_marks = class_marks + _marks_from_expressions(owner.decorator_list, expand)
2443
+ class_marks = class_marks + _marks_from_pytestmark(owner.body, expand)
2444
+ # ``@patch`` on a class (or on a base, whose patched methods are
2445
+ # inherited and patched again) injects into every ``test*`` method.
2446
+ class_injected = _injected_patch_count(cls.decorator_list) + sum(
2447
+ _injected_patch_count(owner.decorator_list) for owner, _ in bases
2448
+ )
2449
+ # Methods: own definitions win over inherited ones.
2450
+ methods: dict[str, tuple[ast.FunctionDef | ast.AsyncFunctionDef, str]] = {}
2451
+ for owner, owner_id in reversed(bases):
2452
+ for f in scope_functions(owner.body):
2453
+ methods[f.name] = (f, owner_id)
2454
+ for f in scope_functions(cls.body):
2455
+ methods[f.name] = (f, class_id)
2456
+ # Test methods bound by assignment in a class body (``test_b =
2457
+ # test_a``, ``test_x = _check``): pytest collects any attribute whose
2458
+ # name matches and whose value is a function. Entry: the function.
2459
+ entries: dict[str, str] = {}
2460
+ for owner, owner_id in [*reversed(bases), (cls, class_id)]:
2461
+ owner_defs = {f.name: f for f in scope_functions(owner.body)}
2462
+ same_module = defined_in.get(owner_id, home) == parsed.module
2463
+ for stmt in owner.body:
2464
+ assigned = _assigned(stmt)
2465
+ if assigned is None:
2466
+ continue
2467
+ bound, value = assigned
2468
+ if not (
2469
+ _matches(functions, bound) or (unittest_style and bound.startswith("test"))
2470
+ ):
2471
+ continue
2472
+ if isinstance(value, ast.Name) and value.id in owner_defs:
2473
+ methods[bound] = (owner_defs[value.id], owner_id)
2474
+ entries[bound] = f"{owner_id}.{value.id}"
2475
+ elif isinstance(value, ast.Name) and same_module and value.id in module_funcs:
2476
+ methods[bound] = (module_funcs[value.id], parsed.module)
2477
+ entries[bound] = parsed.member_id(value.id)
2478
+ elif isinstance(value, (ast.Name, ast.Attribute)):
2479
+ unmodelled(f"{nodeid}::{bound}", ast.unparse(value))
2480
+ setup = [
2481
+ f"{owner_id}.{method}"
2482
+ for method, (_, owner_id) in methods.items()
2483
+ if method in CLASS_SETUP_METHODS
2484
+ ]
2485
+ extra = setup + [class_id] if bases else setup
2486
+ collected = [
2487
+ method
2488
+ for method, (func, _) in methods.items()
2489
+ if not _is_fixture(func)[0]
2490
+ and (_matches(functions, method) or (unittest_style and method.startswith("test")))
2491
+ ]
2492
+ if unittest_style and not any(m.startswith("test") for m in collected):
2493
+ # unittest: a TestCase with no ``test*`` method runs ``runTest``.
2494
+ if "runTest" in methods:
2495
+ collected.append("runTest")
2496
+ for method in sorted(collected):
2497
+ func, owner_id = methods[method]
2498
+ expand = expander(defined_in.get(owner_id, home))
2499
+ marks = class_marks + _marks_from_expressions(func.decorator_list, expand)
2500
+ requests = list(_fixture_requests(func, True, marks, class_injected))
2501
+ requests += list(marks.usefixtures)
2502
+ entry = entries.get(method, f"{owner_id}.{method}")
2503
+ add(f"{nodeid}::{method}", entry, class_levels, requests, extra, marks)
2504
+ # Nested classes, inherited ones too (pytest collects a class's
2505
+ # attributes along its MRO), the nearest definition of a name winning.
2506
+ inners: dict[str, tuple[ast.ClassDef, str, _Scope | None]] = {}
2507
+ for owner, owner_id in reversed(bases):
2508
+ owner_scope = scope_for(defined_in.get(owner_id, home))
2509
+ for inner in scope_classes(owner.body):
2510
+ inners[inner.name] = (inner, owner_id, owner_scope)
2511
+ for inner in scope_classes(cls.body):
2512
+ inners[inner.name] = (inner, class_id, scope)
2513
+ for inner, owner_id, inner_scope in inners.values():
2514
+ walk_class(
2515
+ inner,
2516
+ prefix_ids + [owner_id],
2517
+ nodeid,
2518
+ class_marks,
2519
+ class_levels,
2520
+ scope=inner_scope,
2521
+ imported=imported or owner_id != class_id,
2522
+ )
2523
+
2524
+ def module_test(func: ast.FunctionDef | ast.AsyncFunctionDef, bound: str) -> None:
2525
+ marks = module_marks + _marks_from_expressions(func.decorator_list, own_marks)
2526
+ requests = list(_fixture_requests(func, False, marks)) + list(marks.usefixtures)
2527
+ add(f"{parsed.path}::{bound}", parsed.member_id(func.name), [], requests, [], marks)
2528
+
2529
+ # ``f.__test__ = True``: pytest collects ``f`` whatever it is called.
2530
+ marked_tests: set[str] = set()
2531
+ for stmt in parsed.tree.body:
2532
+ if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1:
2533
+ target = stmt.targets[0]
2534
+ if (
2535
+ isinstance(target, ast.Attribute)
2536
+ and target.attr == "__test__"
2537
+ and isinstance(target.value, ast.Name)
2538
+ and isinstance(stmt.value, ast.Constant)
2539
+ and stmt.value.value is True
2540
+ ):
2541
+ marked_tests.add(target.value.id)
2542
+ for func in scope_functions(parsed.tree.body):
2543
+ if not (_matches(functions, func.name) or func.name in marked_tests):
2544
+ continue
2545
+ if _is_fixture(func)[0]:
2546
+ continue
2547
+ module_test(func, func.name)
2548
+
2549
+ # Tests bound by assignment (``test_alias = test_orig``): pytest collects
2550
+ # any module attribute whose name matches and whose value is a function
2551
+ # or class. A value bound to something not visible here (an attribute,
2552
+ # an imported name: Hypothesis's ``TestMachine = Machine.TestCase``) is
2553
+ # reported; data (``test_data = [...]``, a call's result) is not a test.
2554
+ module_classes_here = {c.name: c for c in scope_classes(parsed.tree.body)}
2555
+ module_defined = {n.name for n in parsed.tree.body if isinstance(n, DEF_NODES)}
2556
+ for stmt in parsed.tree.body:
2557
+ assigned = _assigned(stmt)
2558
+ if assigned is None or assigned[0] in module_defined:
2559
+ continue
2560
+ bound, value = assigned
2561
+ is_function = _matches(functions, bound)
2562
+ is_class = _matches(classes, bound)
2563
+ if not (is_function or is_class):
2564
+ continue
2565
+ if isinstance(value, ast.Name) and value.id in module_funcs and is_function:
2566
+ if not _is_fixture(module_funcs[value.id])[0]:
2567
+ module_test(module_funcs[value.id], bound)
2568
+ elif isinstance(value, ast.Name) and value.id in module_classes_here:
2569
+ target = module_classes_here[value.id]
2570
+ walk_class(
2571
+ target,
2572
+ [],
2573
+ parsed.path,
2574
+ module_marks,
2575
+ name=bound,
2576
+ class_id=parsed.member_id(target.name),
2577
+ )
2578
+ elif isinstance(value, (ast.Name, ast.Attribute)):
2579
+ unmodelled(f"{parsed.path}::{bound}", ast.unparse(value))
2580
+
2581
+ # pytest collects every module attribute matching the naming rules,
2582
+ # including functions and classes imported from elsewhere (fastapi's
2583
+ # tutorial tests import ``test_read_main`` from ``docs_src``).
2584
+ defined = {n.name for n in parsed.tree.body if isinstance(n, DEF_NODES)}
2585
+ for stmt in iter_scope_statements(parsed.tree.body):
2586
+ if not isinstance(stmt, ast.ImportFrom):
2587
+ continue
2588
+ source = _absolute_module(parsed, stmt)
2589
+ aliases = list(stmt.names)
2590
+ if any(a.name == "*" for a in aliases):
2591
+ # ``from tests.test_install import *`` re-runs another module's
2592
+ # tests here (poetry's sync command does exactly this).
2593
+ origin = module_facts(source) if module_facts is not None else None
2594
+ if origin is None:
2595
+ result.notes.append(
2596
+ DiscoveryNote(
2597
+ RUNNER,
2598
+ "imported_test_out_of_scope",
2599
+ f"{parsed.path}: ``from {source} import *`` names a module outside "
2600
+ "the source roots; any tests pytest collects through it are not targets",
2601
+ parsed.path,
2602
+ )
2603
+ )
2604
+ continue
2605
+ aliases = [ast.alias(name=n, asname=None) for n in _star_names(origin.parsed.tree)]
2606
+ for alias in aliases:
2607
+ bound = alias.asname or alias.name
2608
+ if bound in defined:
2609
+ continue
2610
+ is_function = _matches(functions, bound)
2611
+ is_class = _matches(classes, bound)
2612
+ # Followed through re-exports (``tests/base.py`` importing the
2613
+ # class from ``tests/impl.py``) to the module defining it.
2614
+ found = (
2615
+ _definition_bound_to(source, alias.name, module_facts, set())
2616
+ if module_facts is not None
2617
+ else source
2618
+ )
2619
+ origin, node = found if isinstance(found, tuple) else (None, None)
2620
+ if not (is_function or is_class):
2621
+ # Still a test when it is a unittest TestCase (collected
2622
+ # whatever its name): walk any in-scope class it names.
2623
+ if not isinstance(node, ast.ClassDef):
2624
+ continue
2625
+ nodeid = f"{parsed.path}::{bound}"
2626
+ if node is None or origin is None:
2627
+ # Data, or outside the source roots: there, whether pytest
2628
+ # collects anything from it is unknown, so it is reported,
2629
+ # not guessed; except
2630
+ # unittest's names and a test framework's ``*TestCase``
2631
+ # (``from django.test import TestCase``), which yield none.
2632
+ outside = found if isinstance(found, str) else None
2633
+ if outside is not None and not (
2634
+ outside.split(".")[0] == "unittest"
2635
+ or (
2636
+ outside.split(".")[0] in TESTCASE_FRAMEWORKS
2637
+ and alias.name.endswith("TestCase")
2638
+ )
2639
+ ):
2640
+ result.notes.append(
2641
+ DiscoveryNote(
2642
+ RUNNER,
2643
+ "imported_test_out_of_scope",
2644
+ f"{nodeid}: imported from {outside}, outside the source roots; "
2645
+ "any tests pytest collects from it are not targets",
2646
+ parsed.path,
2647
+ )
2648
+ )
2649
+ continue
2650
+ assert isinstance(node, DEF_NODES)
2651
+ entry = origin.parsed.member_id(node.name)
2652
+ if isinstance(node, ast.ClassDef):
2653
+ # The class is collected here as if defined here, with
2654
+ # everything it inherits (urllib3's test_pyopenssl.py imports
2655
+ # TestHTTPS_TLSv1, whose tests are almost all defined on its
2656
+ # bases), its bases resolved where it is defined.
2657
+ origin_scope = scope_for(origin.parsed.module) or (
2658
+ origin.parsed,
2659
+ {c.name: c for c in scope_classes(origin.parsed.tree.body)},
2660
+ {},
2661
+ )
2662
+ walk_class(
2663
+ node,
2664
+ [],
2665
+ parsed.path,
2666
+ module_marks,
2667
+ name=bound,
2668
+ class_id=entry,
2669
+ scope=origin_scope,
2670
+ imported=True,
2671
+ )
2672
+ elif is_function and not _is_fixture(node)[0]:
2673
+ marks = module_marks + _marks_from_expressions(
2674
+ node.decorator_list, expander(origin.parsed.module)
2675
+ )
2676
+ requests = list(_fixture_requests(node, False, marks)) + list(marks.usefixtures)
2677
+ add(nodeid, entry, [], requests, [origin.parsed.module], marks)
2678
+
2679
+ for cls in scope_classes(parsed.tree.body):
2680
+ walk_class(cls, [], parsed.path, module_marks)