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.
- diffcone/__init__.py +13 -0
- diffcone/cache.py +528 -0
- diffcone/check.py +399 -0
- diffcone/classify.py +255 -0
- diffcone/cli.py +886 -0
- diffcone/collect.py +846 -0
- diffcone/cython.py +757 -0
- diffcone/declarations.py +111 -0
- diffcone/discovery/__init__.py +121 -0
- diffcone/discovery/asv_static.py +494 -0
- diffcone/discovery/common.py +130 -0
- diffcone/discovery/pytest_static.py +2680 -0
- diffcone/evidence.py +738 -0
- diffcone/evidence_plan.py +1487 -0
- diffcone/execution.py +1681 -0
- diffcone/indexer/__init__.py +48 -0
- diffcone/indexer/core.py +286 -0
- diffcone/indexer/definitions.py +292 -0
- diffcone/indexer/dynamics.py +406 -0
- diffcone/indexer/facts.py +373 -0
- diffcone/indexer/literals.py +388 -0
- diffcone/indexer/references.py +895 -0
- diffcone/indexer/resolver.py +1001 -0
- diffcone/indexer/scopes.py +276 -0
- diffcone/indexer/state.py +83 -0
- diffcone/indexer/symbols.py +441 -0
- diffcone/indexer/syntax.py +173 -0
- diffcone/manifest.py +166 -0
- diffcone/model.py +191 -0
- diffcone/planner.py +1453 -0
- diffcone/report.py +256 -0
- diffcone/selection.py +133 -0
- diffcone/snapshot.py +739 -0
- diffcone/testing.py +200 -0
- diffcone-0.1.0.dist-info/METADATA +133 -0
- diffcone-0.1.0.dist-info/RECORD +39 -0
- diffcone-0.1.0.dist-info/WHEEL +4 -0
- diffcone-0.1.0.dist-info/entry_points.txt +3 -0
- diffcone-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|