cg-code-graph 0.10.1__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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- codegraph/xcode.py +251 -0
|
@@ -0,0 +1,1040 @@
|
|
|
1
|
+
"""pytest and unittest test code as graph nodes (stdlib `ast`, `configparser`, `tomllib`; no code execution).
|
|
2
|
+
|
|
3
|
+
Discovery (defaults follow pytest; a pytest config at the indexed root or a nested project root overrides them):
|
|
4
|
+
* test files: `python_files` (default `test_*.py`, `*_test.py`), Django / unittest `tests.py`, `conftest.py`,
|
|
5
|
+
every file under a `tests/` or `test/` directory or a configured `testpaths` directory (an application package
|
|
6
|
+
named in `testpaths` contributes only its matching files), and modules named in `pytest_plugins`. A module under `tests/` that application code imports (a library's own test utilities) stays
|
|
7
|
+
application code, and so does an imported `tests.py` that defines no test case;
|
|
8
|
+
* every node the Python plugin declared in test code gets `attrs.test = True`, so `isolate_tests()`
|
|
9
|
+
(codegraph/tests_index.py) turns its calls, references and collection calls into TEST_* edges.
|
|
10
|
+
|
|
11
|
+
Test cases (`test:<qual>` nodes, entry kind `test`, `attrs.framework` = pytest | unittest), each with TEST_CALLS to
|
|
12
|
+
the function or method that holds its code:
|
|
13
|
+
* pytest: functions matching `python_functions` (default `test*`) in collected files, and the matching methods of
|
|
14
|
+
classes matching `python_classes` (default `Test*`, no `__init__`), inherited methods included;
|
|
15
|
+
`@pytest.mark.parametrize` -> `attrs.params` (argument names, literal ids or values, case count), other marks ->
|
|
16
|
+
`attrs.marks`;
|
|
17
|
+
* unittest: `test*` methods of `unittest.TestCase` subclasses (Django `TestCase` / `TransactionTestCase` /
|
|
18
|
+
`SimpleTestCase`, DRF `APITestCase`, ...), with TEST_CALLS to `setUp` / `setUpClass` / `setUpTestData` /
|
|
19
|
+
`tearDown` (and pytest's xunit `setup_method` / `setup_module` ...) of the class and module.
|
|
20
|
+
|
|
21
|
+
Fixtures: `@pytest.fixture` functions in the test module, its classes, every `conftest.py` up the directory tree and
|
|
22
|
+
`pytest_plugins` modules. A test gets TEST_USES to every fixture it requests (parameters, `usefixtures`,
|
|
23
|
+
`request.getfixturevalue('x')`) and to the `autouse=True` fixtures in its scope; a fixture gets TEST_USES to the
|
|
24
|
+
fixtures it requests, resolved in its own scope (a fixture overriding a fixture of the same name reaches the outer one).
|
|
25
|
+
|
|
26
|
+
HTTP tests (TEST_HTTP -> the matching route, matched like client calls in `cg link`): requests on a test client
|
|
27
|
+
(`self.client.get(...)`, a `client` / `admin_client` / `api_client` fixture, `APIClient()`, `TestClient(app)`,
|
|
28
|
+
`app.test_client()`, `httpx.AsyncClient`), with the URL evaluated from literals, f-strings, `%` / `+` / `.format`,
|
|
29
|
+
local variables, class attributes and `self.x = ...` in setUp, or `reverse('ns:name')` / `reverse_lazy` resolved
|
|
30
|
+
through the route names the Django plugin indexed. Requests are collected during indexing and matched after the
|
|
31
|
+
framework plugins built their routes (`match_http`).
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import ast
|
|
36
|
+
import configparser
|
|
37
|
+
import fnmatch
|
|
38
|
+
import re
|
|
39
|
+
from collections import defaultdict
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from ...core.model import EXACT, HEURISTIC, RESOLVED
|
|
43
|
+
|
|
44
|
+
DEFAULT_FILES = ("test_*.py", "*_test.py")
|
|
45
|
+
DEFAULT_CLASSES = ("Test",)
|
|
46
|
+
DEFAULT_FUNCTIONS = ("test",)
|
|
47
|
+
EXTRA_FILES = ("tests.py",) # Django / unittest discovery (`test*.py`) beyond pytest's defaults
|
|
48
|
+
TEST_DIRS = {"tests", "test"}
|
|
49
|
+
CONFIG_FILES = ("pytest.ini", ".pytest.ini", "pyproject.toml", "tox.ini", "setup.cfg")
|
|
50
|
+
SETUP_METHODS = ("setUp", "asyncSetUp", "setUpClass", "setUpTestData", "tearDown", "asyncTearDown", "tearDownClass",
|
|
51
|
+
"setup_method", "teardown_method", "setup_class", "teardown_class", "setup", "teardown")
|
|
52
|
+
SETUP_MODULE = ("setUpModule", "tearDownModule", "setup_module", "teardown_module", "setup_function", "teardown_function")
|
|
53
|
+
HTTP_VERBS = {"get": "GET", "post": "POST", "put": "PUT", "patch": "PATCH", "delete": "DELETE", "head": "HEAD",
|
|
54
|
+
"options": "OPTIONS", "trace": "TRACE"}
|
|
55
|
+
GENERIC_VERBS = {"generic", "request"} # client.generic("POST", url) (Django), client.request("POST", url)
|
|
56
|
+
from ..pyweb.values import str_value
|
|
57
|
+
URL_NAME_FUNCS = {"reverse", "reverse_lazy", "resolve_url", "url_for", "url_path_for"} # Django, Flask, Starlette
|
|
58
|
+
LOCAL_HOSTS = re.compile(r"^[a-z]+://(testserver|localhost|127\.0\.0\.1|0\.0\.0\.0|test|example\.com|\[::1\])(:\d+)?(?=/|$)",
|
|
59
|
+
re.I)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _split(v) -> list[str]:
|
|
63
|
+
if isinstance(v, str):
|
|
64
|
+
return [x for x in re.split(r"[\s,]+", v) if x]
|
|
65
|
+
if isinstance(v, (list, tuple)):
|
|
66
|
+
return [str(x) for x in v if str(x).strip()]
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ini_section(path: Path, section: str) -> dict | None:
|
|
71
|
+
cp = configparser.ConfigParser(interpolation=None, strict=False)
|
|
72
|
+
try:
|
|
73
|
+
cp.read_string(path.read_text(encoding="utf-8", errors="replace"))
|
|
74
|
+
except (configparser.Error, OSError, UnicodeError):
|
|
75
|
+
return None
|
|
76
|
+
if not cp.has_section(section):
|
|
77
|
+
return None
|
|
78
|
+
return dict(cp.items(section))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_pytest_config(d: Path) -> tuple[str, dict] | None:
|
|
82
|
+
"""(file, options) of the pytest config in directory d, in pytest's own precedence order."""
|
|
83
|
+
for fn in CONFIG_FILES:
|
|
84
|
+
p = d / fn
|
|
85
|
+
if not p.is_file():
|
|
86
|
+
continue
|
|
87
|
+
if fn in ("pytest.ini", ".pytest.ini"):
|
|
88
|
+
return fn, _ini_section(p, "pytest") or {}
|
|
89
|
+
if fn == "pyproject.toml":
|
|
90
|
+
try:
|
|
91
|
+
import tomllib
|
|
92
|
+
data = tomllib.loads(p.read_text(encoding="utf-8", errors="replace"))
|
|
93
|
+
except Exception: # noqa: BLE001 (invalid TOML: no pytest settings from it)
|
|
94
|
+
continue
|
|
95
|
+
tool = data.get("tool") if isinstance(data.get("tool"), dict) else {}
|
|
96
|
+
pt = tool.get("pytest") if isinstance(tool.get("pytest"), dict) else None
|
|
97
|
+
if pt is not None:
|
|
98
|
+
opts = pt.get("ini_options") if isinstance(pt.get("ini_options"), dict) else pt
|
|
99
|
+
return f"{fn} [tool.pytest]", opts
|
|
100
|
+
continue
|
|
101
|
+
sect = "pytest" if fn == "tox.ini" else "tool:pytest"
|
|
102
|
+
opts = _ini_section(p, sect)
|
|
103
|
+
if opts is not None:
|
|
104
|
+
return f"{fn} [{sect}]", opts
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Discovery:
|
|
109
|
+
"""pytest naming rules for one project directory."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, base: str, source: str | None = None, opts: dict | None = None):
|
|
112
|
+
opts = opts or {}
|
|
113
|
+
self.base = base
|
|
114
|
+
self.source = source
|
|
115
|
+
self.files = tuple(_split(opts.get("python_files"))) or DEFAULT_FILES
|
|
116
|
+
self.classes = tuple(_split(opts.get("python_classes"))) or DEFAULT_CLASSES
|
|
117
|
+
self.functions = tuple(_split(opts.get("python_functions"))) or DEFAULT_FUNCTIONS
|
|
118
|
+
self.testpaths = tuple(p.strip("/") for p in _split(opts.get("testpaths")) if p.strip("/") not in ("", "."))
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _name_match(name: str, pats) -> bool:
|
|
122
|
+
"""pytest: a pattern with glob characters is a glob, otherwise a prefix."""
|
|
123
|
+
for p in pats:
|
|
124
|
+
if any(ch in p for ch in "*?["):
|
|
125
|
+
if fnmatch.fnmatchcase(name, p):
|
|
126
|
+
return True
|
|
127
|
+
elif name.startswith(p):
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def collects(self, rel: str) -> bool:
|
|
132
|
+
return self.matches_files(rel) or rel.rsplit("/", 1)[-1] in EXTRA_FILES
|
|
133
|
+
|
|
134
|
+
def matches_files(self, rel: str) -> bool:
|
|
135
|
+
"""`python_files` match (not counting the Django / unittest `tests.py` name)."""
|
|
136
|
+
base = rel.rsplit("/", 1)[-1]
|
|
137
|
+
return any(fnmatch.fnmatchcase(base, p) or ("/" in p and fnmatch.fnmatchcase(rel, p)) for p in self.files)
|
|
138
|
+
|
|
139
|
+
def is_test_function(self, name: str) -> bool:
|
|
140
|
+
return self._name_match(name, self.functions)
|
|
141
|
+
|
|
142
|
+
def is_test_class(self, name: str) -> bool:
|
|
143
|
+
return self._name_match(name, self.classes)
|
|
144
|
+
|
|
145
|
+
def as_dict(self) -> dict:
|
|
146
|
+
out = {"dir": self.base or ".", "source": self.source}
|
|
147
|
+
for k, dflt in (("files", DEFAULT_FILES), ("classes", DEFAULT_CLASSES), ("functions", DEFAULT_FUNCTIONS)):
|
|
148
|
+
if getattr(self, k) != dflt:
|
|
149
|
+
out[f"python_{k}"] = list(getattr(self, k))
|
|
150
|
+
if self.testpaths:
|
|
151
|
+
out["testpaths"] = list(self.testpaths)
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _deco_name(d) -> str:
|
|
156
|
+
from .plugin import dotted
|
|
157
|
+
e = d.func if isinstance(d, ast.Call) else d
|
|
158
|
+
return dotted(e) or ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _kw(call, name):
|
|
162
|
+
if isinstance(call, ast.Call):
|
|
163
|
+
for k in call.keywords:
|
|
164
|
+
if k.arg == name:
|
|
165
|
+
return k.value
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _text(e, limit=60) -> str:
|
|
170
|
+
try:
|
|
171
|
+
s = ast.unparse(e)
|
|
172
|
+
except Exception: # noqa: BLE001
|
|
173
|
+
s = "?"
|
|
174
|
+
return s if len(s) <= limit else s[:limit - 1] + "…"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _strs(e) -> list[str] | None:
|
|
178
|
+
"""'a, b' / ['a', 'b'] / ('a',) -> names; None when not literal."""
|
|
179
|
+
if isinstance(e, ast.Constant) and isinstance(e.value, str):
|
|
180
|
+
return [x.strip() for x in e.value.split(",") if x.strip()]
|
|
181
|
+
if isinstance(e, (ast.List, ast.Tuple)) and all(isinstance(x, ast.Constant) and isinstance(x.value, str) for x in e.elts):
|
|
182
|
+
return [x.value for x in e.elts]
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _ph(e) -> str:
|
|
187
|
+
"""Placeholder name for a value interpolated into a URL: `{book.pk}` -> {pk}, `{slug}` -> {slug}."""
|
|
188
|
+
if isinstance(e, ast.FormattedValue):
|
|
189
|
+
e = e.value
|
|
190
|
+
if isinstance(e, ast.Call) and isinstance(e.func, ast.Name) and e.func.id == "str" and e.args:
|
|
191
|
+
e = e.args[0]
|
|
192
|
+
if isinstance(e, ast.Name):
|
|
193
|
+
return e.id
|
|
194
|
+
if isinstance(e, ast.Attribute):
|
|
195
|
+
return e.attr
|
|
196
|
+
if isinstance(e, ast.Subscript) and isinstance(e.slice, ast.Constant) and isinstance(e.slice.value, str):
|
|
197
|
+
return re.sub(r"\W", "_", e.slice.value) or "p"
|
|
198
|
+
return "p"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class PyTests:
|
|
202
|
+
def __init__(self, prog, builder):
|
|
203
|
+
from .plugin import Ctx
|
|
204
|
+
self.prog, self.b, self.Ctx = prog, builder, Ctx
|
|
205
|
+
self.st: dict = {}
|
|
206
|
+
self.test_files: dict[str, str] = {} # rel -> why (pattern | conftest | tests dir | testpaths | pytest_plugins)
|
|
207
|
+
self.pending: list[tuple] = [] # HTTP requests found in test code, matched in match_http()
|
|
208
|
+
self.param_values: dict = {} # parametrize literals of the function collect_http() is reading
|
|
209
|
+
self.fixtures_in: dict = defaultdict(dict) # scope key -> {name: FuncInfo}
|
|
210
|
+
self.fixture_info: dict = {} # FuncInfo.id -> {name, autouse, scope, params}
|
|
211
|
+
self._chains: dict = {} # (class qual, module) -> (fixture lookup chain, autouse fixtures)
|
|
212
|
+
self._own: dict = {} # FuncInfo.id -> (parameters, usefixtures, getfixturevalue names)
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------------ discovery
|
|
215
|
+
def discoveries(self) -> list[Discovery]:
|
|
216
|
+
root = self.prog.root
|
|
217
|
+
try:
|
|
218
|
+
projects = [""] + self.prog.root_plan._nested_projects()
|
|
219
|
+
except Exception: # noqa: BLE001
|
|
220
|
+
projects = [""]
|
|
221
|
+
out = []
|
|
222
|
+
for pd in projects:
|
|
223
|
+
got = read_pytest_config(root / pd if pd else root)
|
|
224
|
+
if got or not pd:
|
|
225
|
+
out.append(Discovery(pd, *(got or (None, None))))
|
|
226
|
+
return sorted(out, key=lambda d: -len(d.base))
|
|
227
|
+
|
|
228
|
+
def rootdir_aliases(self) -> int:
|
|
229
|
+
"""pytest's default import mode puts the directory of a test file that is not in a package on sys.path, so
|
|
230
|
+
its neighbours import each other by bare name (`from helpers import build`, `from conftest import X`). Those
|
|
231
|
+
names become aliases of the modules below such a directory (never replacing an existing name)."""
|
|
232
|
+
prog = self.prog
|
|
233
|
+
self.discs = self.discoveries()
|
|
234
|
+
files = set(prog.by_file)
|
|
235
|
+
dirs = set()
|
|
236
|
+
for rel in files:
|
|
237
|
+
d, _, base = rel.rpartition("/")
|
|
238
|
+
if (base == "conftest.py" or self.discovery_for(rel).collects(rel)) and \
|
|
239
|
+
(f"{d}/__init__.py" if d else "__init__.py") not in files:
|
|
240
|
+
dirs.add(d)
|
|
241
|
+
n = 0
|
|
242
|
+
for d in dirs:
|
|
243
|
+
pre = f"{d}/" if d else ""
|
|
244
|
+
for rel, m in prog.by_file.items():
|
|
245
|
+
if not rel.startswith(pre) or (not d and "/" in rel):
|
|
246
|
+
continue
|
|
247
|
+
name = rel[len(pre):-3].replace("/", ".")
|
|
248
|
+
if name.endswith(".__init__"):
|
|
249
|
+
name = name[:-len(".__init__")]
|
|
250
|
+
if not name.isidentifier() and not all(x.isidentifier() for x in name.split(".")):
|
|
251
|
+
continue
|
|
252
|
+
if name not in prog.alias:
|
|
253
|
+
prog.alias[name] = m
|
|
254
|
+
n += 1
|
|
255
|
+
return n
|
|
256
|
+
|
|
257
|
+
def discovery_for(self, rel: str) -> Discovery:
|
|
258
|
+
for d in self.discs:
|
|
259
|
+
if not d.base or rel.startswith(d.base + "/"):
|
|
260
|
+
return d
|
|
261
|
+
return self.discs[-1]
|
|
262
|
+
|
|
263
|
+
def whole_test_dir(self, d: str) -> bool:
|
|
264
|
+
"""A `testpaths` directory is test code as a whole when it is a test directory (`tests/`, `testing/`, ...) or
|
|
265
|
+
not an importable package. An application package named in `testpaths` (`testpaths = ["app"]`) is only
|
|
266
|
+
searched: pytest collects the files in it that match `python_files`, so its other modules stay application
|
|
267
|
+
code."""
|
|
268
|
+
name = d.rsplit("/", 1)[-1].lower()
|
|
269
|
+
return name in TEST_DIRS or name.startswith("test") or f"{d}/__init__.py" not in self.prog.by_file
|
|
270
|
+
|
|
271
|
+
def classify(self) -> None:
|
|
272
|
+
prog = self.prog
|
|
273
|
+
self.discs = self.discoveries()
|
|
274
|
+
roots = {r.path for r in prog.root_plan.roots.values() if r.path}
|
|
275
|
+
tp_dirs = []
|
|
276
|
+
for d in self.discs:
|
|
277
|
+
for tp in d.testpaths:
|
|
278
|
+
full = f"{d.base}/{tp}" if d.base else tp
|
|
279
|
+
for x in sorted(prog.root_plan.dirs | {full}) if any(ch in tp for ch in "*?[") else [full]:
|
|
280
|
+
if fnmatch.fnmatchcase(x, full) and x not in roots and not any(r.startswith(x + "/") for r in roots) \
|
|
281
|
+
and self.whole_test_dir(x):
|
|
282
|
+
tp_dirs.append(x)
|
|
283
|
+
# a tests/ or test/ directory counts when some test file or conftest.py lives below it (a `test/` package of
|
|
284
|
+
# Jinja test plugins inside an application package does not)
|
|
285
|
+
holding = set()
|
|
286
|
+
for rel in prog.by_file:
|
|
287
|
+
d = self.discovery_for(rel)
|
|
288
|
+
if rel.rsplit("/", 1)[-1] == "conftest.py" or d.collects(rel):
|
|
289
|
+
parts = rel.split("/")[:-1]
|
|
290
|
+
holding.update("/".join(parts[:i + 1]) for i, p in enumerate(parts) if p in TEST_DIRS)
|
|
291
|
+
weak = set()
|
|
292
|
+
for rel, m in prog.by_file.items():
|
|
293
|
+
d = self.discovery_for(rel)
|
|
294
|
+
parts = rel.split("/")
|
|
295
|
+
if rel.rsplit("/", 1)[-1] == "conftest.py":
|
|
296
|
+
self.test_files[rel] = "conftest"
|
|
297
|
+
elif d.collects(rel):
|
|
298
|
+
self.test_files[rel] = "pattern"
|
|
299
|
+
if not d.matches_files(rel) and not self.defines_tests(m, d):
|
|
300
|
+
weak.add(rel) # a module named tests.py without test cases (application code if imported)
|
|
301
|
+
elif any(rel.startswith(t + "/") for t in tp_dirs):
|
|
302
|
+
self.test_files[rel] = "testpaths"
|
|
303
|
+
elif any(p in TEST_DIRS and "/".join(parts[:i + 1]) in holding for i, p in enumerate(parts[:-1])):
|
|
304
|
+
self.test_files[rel] = "tests dir"
|
|
305
|
+
weak.add(rel)
|
|
306
|
+
# modules named in pytest_plugins are fixture providers
|
|
307
|
+
for rel, why in list(self.test_files.items()):
|
|
308
|
+
m = prog.by_file[rel]
|
|
309
|
+
for val, _ln, _ann in m.vars.get("pytest_plugins", ()):
|
|
310
|
+
for name in _strs(val) or []:
|
|
311
|
+
pm = prog.module(name)
|
|
312
|
+
if pm is not None and pm.file not in self.test_files:
|
|
313
|
+
self.test_files[pm.file] = "pytest_plugins"
|
|
314
|
+
# a library's own test utilities (django/test/, a package's testing helpers under test/), and a `tests.py`
|
|
315
|
+
# module without test cases, imported by application code stay application code
|
|
316
|
+
if weak:
|
|
317
|
+
imps = defaultdict(set)
|
|
318
|
+
for e in self.b.edges.values():
|
|
319
|
+
if e.kind == "IMPORTS":
|
|
320
|
+
imps[e.dst].add(e.src)
|
|
321
|
+
app = {f"module:{m.name}" for rel, m in prog.by_file.items() if rel not in self.test_files}
|
|
322
|
+
changed = True
|
|
323
|
+
while changed:
|
|
324
|
+
changed = False
|
|
325
|
+
for rel in list(weak):
|
|
326
|
+
mid = f"module:{prog.by_file[rel].name}"
|
|
327
|
+
pkg_dir = rel[:-len("__init__.py")] if rel.endswith("/__init__.py") else None
|
|
328
|
+
if imps.get(mid, set()) & app or (pkg_dir and any(
|
|
329
|
+
r.startswith(pkg_dir) and r not in self.test_files for r in prog.by_file)):
|
|
330
|
+
weak.discard(rel)
|
|
331
|
+
del self.test_files[rel]
|
|
332
|
+
app.add(mid)
|
|
333
|
+
changed = True
|
|
334
|
+
|
|
335
|
+
def defines_tests(self, m, disc: Discovery) -> bool:
|
|
336
|
+
"""The module holds a test case: a top-level test function, a `Test*` class or a TestCase subclass."""
|
|
337
|
+
if any(disc.is_test_function(n) for n in m.funcs):
|
|
338
|
+
return True
|
|
339
|
+
return any(disc.is_test_class(c.name) or self.is_unittest_class(c) for c in m.classes.values())
|
|
340
|
+
|
|
341
|
+
# ------------------------------------------------------------------ marking
|
|
342
|
+
def mark(self) -> int:
|
|
343
|
+
n = 0
|
|
344
|
+
tf = self.test_files
|
|
345
|
+
for node in self.b.nodes.values():
|
|
346
|
+
if node.lang == "python" and node.file in tf and node.kind in ("module", "class", "function", "method", "script"):
|
|
347
|
+
node.attrs = {**(node.attrs or {}), "test": True}
|
|
348
|
+
if node.kind == "script" and node.entry_kind:
|
|
349
|
+
node.entry_kind = "test" # `python tests/test_x.py` runs the tests
|
|
350
|
+
n += 1
|
|
351
|
+
return n
|
|
352
|
+
|
|
353
|
+
# ------------------------------------------------------------------ fixtures
|
|
354
|
+
@staticmethod
|
|
355
|
+
def fixture_deco(f):
|
|
356
|
+
for d in f.decorators:
|
|
357
|
+
nm = _deco_name(d).split(".")[-1]
|
|
358
|
+
if nm in ("fixture", "yield_fixture"):
|
|
359
|
+
return d
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
def collect_fixtures(self) -> None:
|
|
363
|
+
prog = self.prog
|
|
364
|
+
for f in prog.funcs.values():
|
|
365
|
+
d = self.fixture_deco(f)
|
|
366
|
+
if d is None:
|
|
367
|
+
continue
|
|
368
|
+
name = None
|
|
369
|
+
nv = _kw(d, "name")
|
|
370
|
+
if isinstance(nv, ast.Constant) and isinstance(nv.value, str):
|
|
371
|
+
name = nv.value
|
|
372
|
+
au = _kw(d, "autouse")
|
|
373
|
+
info = {"name": name or f.name, "autouse": isinstance(au, ast.Constant) and au.value is True}
|
|
374
|
+
sc = _kw(d, "scope")
|
|
375
|
+
if isinstance(sc, ast.Constant) and isinstance(sc.value, str):
|
|
376
|
+
info["scope"] = sc.value
|
|
377
|
+
pv = _kw(d, "params")
|
|
378
|
+
if isinstance(pv, (ast.List, ast.Tuple)):
|
|
379
|
+
info["params"] = {"cases": len(pv.elts), "values": [_text(x, 40) for x in pv.elts[:10]]}
|
|
380
|
+
self.fixture_info[f.id] = info
|
|
381
|
+
self.fixtures_in[self.scope_key(f)][info["name"]] = f
|
|
382
|
+
# fixtures imported into a conftest / test module, and the fixtures of its pytest_plugins modules
|
|
383
|
+
by_id = {f.id: f for f in prog.funcs.values() if f.id in self.fixture_info}
|
|
384
|
+
for rel in self.test_files:
|
|
385
|
+
m = prog.by_file[rel]
|
|
386
|
+
key = ("mod", m.name)
|
|
387
|
+
for local, imp in m.imports.items():
|
|
388
|
+
if imp[0] != "sym":
|
|
389
|
+
continue
|
|
390
|
+
t = prog._resolve_import(imp)
|
|
391
|
+
if t and t[0] == "func" and t[1].id in by_id and local not in self.fixtures_in[key]:
|
|
392
|
+
self.fixtures_in[key][self.fixture_info[t[1].id]["name"]] = t[1]
|
|
393
|
+
for val, _ln, _ann in m.vars.get("pytest_plugins", ()):
|
|
394
|
+
for name in _strs(val) or []:
|
|
395
|
+
pm = prog.module(name)
|
|
396
|
+
if pm is None:
|
|
397
|
+
continue
|
|
398
|
+
for fname, pf in self.fixtures_in.get(("mod", pm.name), {}).items():
|
|
399
|
+
# a root conftest's plugins are visible everywhere below it
|
|
400
|
+
self.fixtures_in[key].setdefault(fname, pf)
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def scope_key(f) -> tuple:
|
|
404
|
+
return ("cls", f.cls.qual) if f.cls is not None else ("mod", f.module.name)
|
|
405
|
+
|
|
406
|
+
def conftest_chain(self, rel: str) -> list[tuple]:
|
|
407
|
+
"""Module scope keys of the conftest.py files from rel's directory up to the indexed root."""
|
|
408
|
+
parts = rel.split("/")[:-1]
|
|
409
|
+
out = []
|
|
410
|
+
for i in range(len(parts), -1, -1):
|
|
411
|
+
d = "/".join(parts[:i])
|
|
412
|
+
m = self.prog.by_file.get(f"{d}/conftest.py" if d else "conftest.py")
|
|
413
|
+
if m is not None:
|
|
414
|
+
out.append(("mod", m.name))
|
|
415
|
+
return out
|
|
416
|
+
|
|
417
|
+
def scope_chain(self, f_or_cls, module) -> list[tuple]:
|
|
418
|
+
"""Fixture lookup order for code in class `c` (or a module function): class and its local bases, the module,
|
|
419
|
+
then the conftest files up the tree."""
|
|
420
|
+
out = []
|
|
421
|
+
c = f_or_cls
|
|
422
|
+
if c is not None:
|
|
423
|
+
out.append(("cls", c.qual))
|
|
424
|
+
out += [("cls", b[1].qual) for b in self.prog.mro(c) if b[0] == "type"]
|
|
425
|
+
o = c.outer
|
|
426
|
+
while o is not None:
|
|
427
|
+
out.append(("cls", o.qual))
|
|
428
|
+
o = o.outer
|
|
429
|
+
out.append(("mod", module.name))
|
|
430
|
+
out += [k for k in self.conftest_chain(module.file) if k != ("mod", module.name)]
|
|
431
|
+
return out
|
|
432
|
+
|
|
433
|
+
def lookup_fixture(self, name: str, chain: list[tuple], skip=None):
|
|
434
|
+
for key in chain:
|
|
435
|
+
f = self.fixtures_in.get(key, {}).get(name)
|
|
436
|
+
if f is not None and f is not skip:
|
|
437
|
+
return f
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
@staticmethod
|
|
441
|
+
def requested(fnode, drop=()) -> list[str]:
|
|
442
|
+
"""Fixture names a test / fixture function requests by its parameters (no default value)."""
|
|
443
|
+
a = fnode.args
|
|
444
|
+
pos = list(a.posonlyargs) + list(a.args)
|
|
445
|
+
n_def = len(a.defaults)
|
|
446
|
+
names = [x.arg for x in (pos[:len(pos) - n_def] if n_def else pos)]
|
|
447
|
+
names += [x.arg for x, dv in zip(a.kwonlyargs, a.kw_defaults) if dv is None]
|
|
448
|
+
return [n for n in names if n not in ("self", "cls") and n not in drop]
|
|
449
|
+
|
|
450
|
+
@staticmethod
|
|
451
|
+
def dynamic_fixtures(fnode) -> list[str]:
|
|
452
|
+
from .plugin import walk_body
|
|
453
|
+
out = []
|
|
454
|
+
for sub in walk_body(fnode):
|
|
455
|
+
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) and sub.func.attr == "getfixturevalue" \
|
|
456
|
+
and sub.args and isinstance(sub.args[0], ast.Constant) and isinstance(sub.args[0].value, str):
|
|
457
|
+
out.append(sub.args[0].value)
|
|
458
|
+
return out
|
|
459
|
+
|
|
460
|
+
def usefixtures(self, decorators) -> list[str]:
|
|
461
|
+
out = []
|
|
462
|
+
for d in decorators or ():
|
|
463
|
+
if isinstance(d, ast.Call) and _deco_name(d).split(".")[-1] == "usefixtures":
|
|
464
|
+
out += [x.value for x in d.args if isinstance(x, ast.Constant) and isinstance(x.value, str)]
|
|
465
|
+
return out
|
|
466
|
+
|
|
467
|
+
def module_marks(self, m) -> list:
|
|
468
|
+
out = []
|
|
469
|
+
for val, _ln, _ann in m.vars.get("pytestmark", ()):
|
|
470
|
+
out += list(val.elts) if isinstance(val, (ast.List, ast.Tuple)) else [val]
|
|
471
|
+
return out
|
|
472
|
+
|
|
473
|
+
def link_fixtures(self, src_id: str, names: list[str], chain: list[tuple], file, line, via="fixture", skip=None) -> int:
|
|
474
|
+
n = 0
|
|
475
|
+
for name in dict.fromkeys(names):
|
|
476
|
+
fx = self.lookup_fixture(name, chain, skip)
|
|
477
|
+
if fx is None:
|
|
478
|
+
continue
|
|
479
|
+
self.b.add_edge(src_id, fx.id, "TEST_USES", file, line, RESOLVED, via=via, fixture=name)
|
|
480
|
+
n += 1
|
|
481
|
+
return n
|
|
482
|
+
|
|
483
|
+
def fixture_edges(self) -> int:
|
|
484
|
+
"""Fixture -> the fixtures it requests (in its own scope)."""
|
|
485
|
+
n = 0
|
|
486
|
+
prog = self.prog
|
|
487
|
+
for fid, info in self.fixture_info.items():
|
|
488
|
+
f = prog.funcs.get(fid.split(":", 1)[1])
|
|
489
|
+
if f is None:
|
|
490
|
+
continue
|
|
491
|
+
chain = self.scope_chain(f.cls, f.module)
|
|
492
|
+
names = self.requested(f.node, drop=("request",)) + self.usefixtures(f.decorators) + self.dynamic_fixtures(f.node)
|
|
493
|
+
n += self.link_fixtures(f.id, names, chain, f.file, f.line, skip=f)
|
|
494
|
+
return n
|
|
495
|
+
|
|
496
|
+
def autouse(self, chain: list[tuple]) -> list:
|
|
497
|
+
seen, out = set(), []
|
|
498
|
+
for key in chain:
|
|
499
|
+
for name, f in self.fixtures_in.get(key, {}).items():
|
|
500
|
+
if name in seen:
|
|
501
|
+
continue
|
|
502
|
+
seen.add(name)
|
|
503
|
+
if self.fixture_info.get(f.id, {}).get("autouse"):
|
|
504
|
+
out.append(f)
|
|
505
|
+
return out
|
|
506
|
+
|
|
507
|
+
# ------------------------------------------------------------------ test cases
|
|
508
|
+
def is_unittest_class(self, c) -> bool:
|
|
509
|
+
return self.testcase_base(c) is not None
|
|
510
|
+
|
|
511
|
+
def testcase_base(self, c) -> str | None:
|
|
512
|
+
"""Short name of the nearest `...TestCase` ancestor (unittest / Django / DRF), None for other classes."""
|
|
513
|
+
return next((x.rsplit(".", 1)[-1] for x in self.prog.lineage(c) if x.rsplit(".", 1)[-1].endswith("TestCase")), None)
|
|
514
|
+
|
|
515
|
+
def has_init(self, c) -> bool:
|
|
516
|
+
return self.prog.find_method(c, "__init__") is not None
|
|
517
|
+
|
|
518
|
+
@staticmethod
|
|
519
|
+
def disabled(c) -> bool:
|
|
520
|
+
v = c.attrs.get("__test__")
|
|
521
|
+
return v is not None and isinstance(v[0], ast.Constant) and v[0].value is False
|
|
522
|
+
|
|
523
|
+
def params_of(self, decorators) -> tuple[list, list, list]:
|
|
524
|
+
"""(parametrize records, argnames, other mark names) from a decorator list."""
|
|
525
|
+
params, names, marks = [], [], []
|
|
526
|
+
for d in decorators or ():
|
|
527
|
+
dn = _deco_name(d)
|
|
528
|
+
last = dn.split(".")[-1]
|
|
529
|
+
if last == "parametrize" and isinstance(d, ast.Call) and d.args:
|
|
530
|
+
an = _strs(d.args[0]) or []
|
|
531
|
+
names += an
|
|
532
|
+
rec = {"names": an}
|
|
533
|
+
vals = d.args[1] if len(d.args) > 1 else _kw(d, "argvalues")
|
|
534
|
+
if isinstance(vals, (ast.List, ast.Tuple, ast.Set)):
|
|
535
|
+
rec["cases"] = len(vals.elts)
|
|
536
|
+
ids = _kw(d, "ids")
|
|
537
|
+
lits = _strs(ids) if isinstance(ids, (ast.List, ast.Tuple)) else None
|
|
538
|
+
if lits:
|
|
539
|
+
rec["ids"] = lits[:20]
|
|
540
|
+
else:
|
|
541
|
+
rec["values"] = [_text(x.args[0] if isinstance(x, ast.Call) and _deco_name(x).endswith("param") and len(x.args) == 1
|
|
542
|
+
else x, 40) for x in vals.elts[:20]]
|
|
543
|
+
params.append(rec)
|
|
544
|
+
elif ".mark." in f".{dn}" and last not in ("usefixtures", "parametrize"):
|
|
545
|
+
marks.append(last)
|
|
546
|
+
return params, names, marks
|
|
547
|
+
|
|
548
|
+
def add_case(self, key: str, name: str, f, framework: str, extra: dict | None = None) -> str:
|
|
549
|
+
from .plugin import module_of
|
|
550
|
+
attrs = {"framework": framework, "test": True, **(extra or {})}
|
|
551
|
+
tid = self.b.add_node("test", key, name=name, fqn=key, file=f.file, line=f.line,
|
|
552
|
+
end_line=getattr(f.node, "end_lineno", None), module=module_of(f.file), lang="python",
|
|
553
|
+
entry_kind="test", attrs=attrs)
|
|
554
|
+
self.b.add_edge(tid, f.id, "TEST_CALLS", f.file, f.line, EXACT, via="test method" if f.cls else "test function")
|
|
555
|
+
return tid
|
|
556
|
+
|
|
557
|
+
def cases(self) -> dict:
|
|
558
|
+
prog = self.prog
|
|
559
|
+
counts = defaultdict(int)
|
|
560
|
+
st = defaultdict(int)
|
|
561
|
+
for rel in sorted(self.test_files):
|
|
562
|
+
m = prog.by_file[rel]
|
|
563
|
+
disc = self.discovery_for(rel)
|
|
564
|
+
collect = disc.collects(rel) and self.test_files[rel] != "conftest"
|
|
565
|
+
mod_setup = [m.funcs[n] for n in SETUP_MODULE if n in m.funcs]
|
|
566
|
+
mmarks = self.module_marks(m)
|
|
567
|
+
mod_params, mod_argnames, mod_marknames = self.params_of(mmarks)
|
|
568
|
+
mod_use = self.usefixtures(mmarks)
|
|
569
|
+
if collect:
|
|
570
|
+
for name, f in m.funcs.items():
|
|
571
|
+
if not disc.is_test_function(name) or f.id in self.fixture_info:
|
|
572
|
+
continue
|
|
573
|
+
params, argnames, marks = self.params_of(f.decorators)
|
|
574
|
+
params, argnames, marks = mod_params + params, mod_argnames + argnames, mod_marknames + marks
|
|
575
|
+
extra = self._extra(params, marks)
|
|
576
|
+
tid = self.add_case(f.qual, name, f, "pytest", extra=extra)
|
|
577
|
+
counts["pytest"] += 1
|
|
578
|
+
st["parametrized"] += bool(params)
|
|
579
|
+
self._wire(tid, f, None, m, argnames, mod_use, mod_setup, st)
|
|
580
|
+
for c in m.all_classes:
|
|
581
|
+
unit = self.testcase_base(c)
|
|
582
|
+
if not unit:
|
|
583
|
+
if not collect or not disc.is_test_class(c.name) or self.has_init(c) or self.disabled(c):
|
|
584
|
+
continue
|
|
585
|
+
o = c.outer
|
|
586
|
+
if o is not None and not (disc.is_test_class(o.name) and not self.is_unittest_class(o)):
|
|
587
|
+
continue
|
|
588
|
+
elif self.disabled(c):
|
|
589
|
+
continue
|
|
590
|
+
cparams, cnames, cmarks = self.params_of(c.decorators)
|
|
591
|
+
cuse = self.usefixtures(c.decorators)
|
|
592
|
+
klasses = [c] + [b[1] for b in prog.mro(c) if b[0] == "type"]
|
|
593
|
+
setup = []
|
|
594
|
+
for sn in SETUP_METHODS:
|
|
595
|
+
sf = prog.find_method(c, sn)
|
|
596
|
+
if sf is not None and sf.file in self.test_files:
|
|
597
|
+
setup.append(sf)
|
|
598
|
+
seen = set()
|
|
599
|
+
for k in klasses:
|
|
600
|
+
for mn, f in k.methods.items():
|
|
601
|
+
if mn in seen:
|
|
602
|
+
continue
|
|
603
|
+
seen.add(mn)
|
|
604
|
+
ok = mn.startswith("test") if unit else disc.is_test_function(mn)
|
|
605
|
+
if not ok or f.id in self.fixture_info or not isinstance(f.node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
606
|
+
continue
|
|
607
|
+
params, argnames, marks = self.params_of(f.decorators)
|
|
608
|
+
params = mod_params + cparams + params
|
|
609
|
+
argnames = mod_argnames + cnames + argnames
|
|
610
|
+
marks = mod_marknames + cmarks + marks
|
|
611
|
+
extra = self._extra(params, marks)
|
|
612
|
+
if f.cls is not c:
|
|
613
|
+
extra["inherited_from"] = f.cls.qual
|
|
614
|
+
if unit:
|
|
615
|
+
extra["testcase"] = unit
|
|
616
|
+
fw = "unittest" if unit else "pytest"
|
|
617
|
+
rel_name = c.qual[len(m.name) + 1:] if c.qual.startswith(m.name + ".") else c.name
|
|
618
|
+
tid = self.add_case(f"{c.qual}.{mn}", f"{rel_name}.{mn}", f, fw, extra=extra)
|
|
619
|
+
# a test node for an inherited method sits at the subclass
|
|
620
|
+
if f.cls is not c:
|
|
621
|
+
self.b.nodes[tid].file, self.b.nodes[tid].line = c.file, c.line
|
|
622
|
+
counts[fw] += 1
|
|
623
|
+
st["parametrized"] += bool(params)
|
|
624
|
+
for sf in setup:
|
|
625
|
+
self.b.add_edge(tid, sf.id, "TEST_CALLS", sf.file, sf.line, RESOLVED, via=sf.name)
|
|
626
|
+
self._wire(tid, f, c, m, argnames, mod_use + cuse, mod_setup, st)
|
|
627
|
+
return {"cases": dict(counts), **dict(st)}
|
|
628
|
+
|
|
629
|
+
@staticmethod
|
|
630
|
+
def _extra(params, marks) -> dict:
|
|
631
|
+
out = {}
|
|
632
|
+
if params:
|
|
633
|
+
out["params"] = params
|
|
634
|
+
n = 1
|
|
635
|
+
for p in params:
|
|
636
|
+
n = n * p["cases"] if isinstance(p.get("cases"), int) and n is not None else None
|
|
637
|
+
if n is not None:
|
|
638
|
+
out["cases"] = n
|
|
639
|
+
if marks:
|
|
640
|
+
out["marks"] = sorted(set(marks))
|
|
641
|
+
return out
|
|
642
|
+
|
|
643
|
+
def _wire(self, tid, f, c, m, argnames, use, mod_setup, st) -> None:
|
|
644
|
+
for sf in mod_setup:
|
|
645
|
+
self.b.add_edge(tid, sf.id, "TEST_CALLS", sf.file, sf.line, RESOLVED, via=sf.name)
|
|
646
|
+
if not self.fixture_info:
|
|
647
|
+
return
|
|
648
|
+
key = (c.qual if c is not None else None, m.name)
|
|
649
|
+
cached = self._chains.get(key)
|
|
650
|
+
if cached is None:
|
|
651
|
+
chain = self.scope_chain(c, m)
|
|
652
|
+
cached = self._chains[key] = (chain, self.autouse(chain))
|
|
653
|
+
chain, auto = cached
|
|
654
|
+
fk = f.id
|
|
655
|
+
own = self._own.get(fk)
|
|
656
|
+
if own is None:
|
|
657
|
+
own = self._own[fk] = (self.requested(f.node), self.usefixtures(f.decorators), self.dynamic_fixtures(f.node))
|
|
658
|
+
drop = set(argnames) | {"request"}
|
|
659
|
+
names = [n for n in own[0] if n not in drop] + own[1] + use + own[2]
|
|
660
|
+
st["fixture_uses"] += self.link_fixtures(tid, names, chain, f.file, f.line)
|
|
661
|
+
for fx in auto:
|
|
662
|
+
self.b.add_edge(tid, fx.id, "TEST_USES", fx.file, fx.line, RESOLVED, via="autouse fixture",
|
|
663
|
+
fixture=self.fixture_info[fx.id]["name"])
|
|
664
|
+
st["autouse_uses"] += 1
|
|
665
|
+
|
|
666
|
+
# ------------------------------------------------------------------ HTTP requests
|
|
667
|
+
def is_client(self, recv, ctx, depth=0) -> bool:
|
|
668
|
+
from .plugin import dotted
|
|
669
|
+
if depth > 3:
|
|
670
|
+
return False
|
|
671
|
+
if isinstance(recv, ast.Name) and ctx.func is not None: # c = APIClient() / with app.test_client() as c
|
|
672
|
+
lv = self.prog.local_vars(ctx).get(recv.id, ())
|
|
673
|
+
for val, _ann, kind in lv:
|
|
674
|
+
if kind in ("assign", "with") and isinstance(val, ast.Call) and self.is_client(val, ctx, depth + 1):
|
|
675
|
+
return True
|
|
676
|
+
if any(kind == "param" for _v, _a, kind in lv): # a fixture that returns / yields a client
|
|
677
|
+
fx = self.lookup_fixture(recv.id, self.scope_chain(ctx.func.cls, ctx.func.module), skip=ctx.func)
|
|
678
|
+
if fx is not None and self.fixture_is_client(fx, depth + 1):
|
|
679
|
+
return True
|
|
680
|
+
d = dotted(recv)
|
|
681
|
+
if d and d.rsplit(".", 1)[-1].lower().endswith("client"):
|
|
682
|
+
return True
|
|
683
|
+
if isinstance(recv, ast.Call):
|
|
684
|
+
fd = dotted(recv.func) or ""
|
|
685
|
+
last = fd.rsplit(".", 1)[-1]
|
|
686
|
+
if last == "test_client" or last.endswith("Client"):
|
|
687
|
+
return True
|
|
688
|
+
try:
|
|
689
|
+
t = self.prog.infer(recv, ctx)
|
|
690
|
+
except RecursionError:
|
|
691
|
+
return False
|
|
692
|
+
if t and t[0] in ("einst", "ext"):
|
|
693
|
+
last = str(t[1]).rsplit(".", 1)[-1]
|
|
694
|
+
return last == "test_client" or last.endswith("Client")
|
|
695
|
+
if t and t[0] == "inst":
|
|
696
|
+
return t[1].name.endswith("Client") or any(x.rsplit(".", 1)[-1].endswith("Client") for x in self.prog.lineage(t[1]))
|
|
697
|
+
return False
|
|
698
|
+
|
|
699
|
+
def fixture_is_client(self, fx, depth) -> bool:
|
|
700
|
+
cache = self.__dict__.setdefault("_fx_client", {})
|
|
701
|
+
if fx.id in cache:
|
|
702
|
+
return cache[fx.id]
|
|
703
|
+
cache[fx.id] = False
|
|
704
|
+
from .plugin import walk_body
|
|
705
|
+
ctx = self.Ctx(fx.module, fx, fx.cls)
|
|
706
|
+
cache[fx.id] = any(isinstance(sub, (ast.Return, ast.Yield)) and sub.value is not None
|
|
707
|
+
and self.is_client(sub.value, ctx, depth) for sub in walk_body(fx.node))
|
|
708
|
+
return cache[fx.id]
|
|
709
|
+
|
|
710
|
+
def url_values(self, e, ctx, depth=0) -> list[tuple]:
|
|
711
|
+
"""[("path", "/books/{pk}/") | ("name", "shop:book-detail")] for a URL expression; [] when unknown."""
|
|
712
|
+
if e is None or depth > 6:
|
|
713
|
+
return []
|
|
714
|
+
prog = self.prog
|
|
715
|
+
if isinstance(e, ast.Constant) and isinstance(e.value, str):
|
|
716
|
+
return [("path", e.value)]
|
|
717
|
+
if isinstance(e, ast.JoinedStr):
|
|
718
|
+
outs = [""]
|
|
719
|
+
for v in e.values:
|
|
720
|
+
if isinstance(v, ast.Constant):
|
|
721
|
+
outs = [o + str(v.value) for o in outs]
|
|
722
|
+
continue
|
|
723
|
+
lits = self.param_values.get(v.value.id) if isinstance(v.value, ast.Name) else None
|
|
724
|
+
if not lits and isinstance(v.value, (ast.Name, ast.Attribute)):
|
|
725
|
+
sv = str_value(prog, v.value, ctx) # f"{settings.API_V1_STR}/items/": a constant prefix
|
|
726
|
+
lits = [sv] if sv and "{" not in sv else None
|
|
727
|
+
outs = [o + x for o in outs for x in lits][:6] if lits else [o + "{" + _ph(v) + "}" for o in outs]
|
|
728
|
+
return [("path", o) for o in outs]
|
|
729
|
+
if isinstance(e, ast.BinOp) and isinstance(e.op, ast.Mod):
|
|
730
|
+
out = []
|
|
731
|
+
for k, v in self.url_values(e.left, ctx, depth + 1):
|
|
732
|
+
if k == "path":
|
|
733
|
+
out.append(("path", re.sub(r"%\((\w+)\)[sdi]|%[sdi]", lambda mm: "{" + (mm.group(1) or "p") + "}", v)))
|
|
734
|
+
return out
|
|
735
|
+
if isinstance(e, ast.BinOp) and isinstance(e.op, ast.Add):
|
|
736
|
+
left = [v for k, v in self.url_values(e.left, ctx, depth + 1) if k == "path"]
|
|
737
|
+
if not left:
|
|
738
|
+
return []
|
|
739
|
+
right = [v for k, v in self.url_values(e.right, ctx, depth + 1) if k == "path"] or ["{" + _ph(e.right) + "}"]
|
|
740
|
+
return [("path", a + b) for a in left[:3] for b in right[:3]][:3]
|
|
741
|
+
if isinstance(e, ast.Call):
|
|
742
|
+
from .plugin import dotted
|
|
743
|
+
fn = e.func
|
|
744
|
+
last = (dotted(fn) or "").rsplit(".", 1)[-1]
|
|
745
|
+
v = str_value(prog, e.args[0], ctx) if last in URL_NAME_FUNCS and e.args else None
|
|
746
|
+
if v is not None and "{" not in v:
|
|
747
|
+
return [("path", v)] if v.startswith("/") else [("name", v)]
|
|
748
|
+
if isinstance(fn, ast.Attribute) and fn.attr == "format":
|
|
749
|
+
return [("path", re.sub(r"\{(\w*)[^{}]*\}", lambda mm: "{" + (mm.group(1) if mm.group(1) and not mm.group(1).isdigit() else "p") + "}", v))
|
|
750
|
+
for k, v in self.url_values(fn.value, ctx, depth + 1) if k == "path"]
|
|
751
|
+
return []
|
|
752
|
+
if isinstance(e, ast.Name):
|
|
753
|
+
if ctx.func is not None:
|
|
754
|
+
lv = prog.local_vars(ctx)
|
|
755
|
+
if e.id in lv:
|
|
756
|
+
out = []
|
|
757
|
+
for val, _ann, kind in lv[e.id]:
|
|
758
|
+
if kind == "assign" and val is not None:
|
|
759
|
+
out += self.url_values(val, ctx, depth + 1)
|
|
760
|
+
return out[:3]
|
|
761
|
+
r = prog.resolve_name(ctx.mod, e.id)
|
|
762
|
+
if r and r[0] == "var":
|
|
763
|
+
out = []
|
|
764
|
+
for val, _ln, _ann in r[1].vars.get(r[2], ()):
|
|
765
|
+
out += self.url_values(val, self.Ctx(r[1], None, None), depth + 1)
|
|
766
|
+
return out[:3]
|
|
767
|
+
sv = str_value(prog, e, ctx) # an imported constant (`from app.urls import ITEMS`)
|
|
768
|
+
return [("path", sv)] if sv is not None else []
|
|
769
|
+
if isinstance(e, ast.Attribute) and not (isinstance(e.value, ast.Name) and e.value.id in ("self", "cls")):
|
|
770
|
+
sv = str_value(prog, e, ctx) # settings.API_V1_STR, urls.ITEMS
|
|
771
|
+
return [("path", sv)] if sv is not None else []
|
|
772
|
+
if isinstance(e, ast.Attribute) and isinstance(e.value, ast.Name) and e.value.id in ("self", "cls") and ctx.cls is not None:
|
|
773
|
+
c = ctx.cls
|
|
774
|
+
for k in [c] + [b[1] for b in prog.mro(c) if b[0] == "type"]:
|
|
775
|
+
if e.attr in k.attrs and k.attrs[e.attr][0] is not None:
|
|
776
|
+
return self.url_values(k.attrs[e.attr][0], self.Ctx(k.module, None, k), depth + 1)
|
|
777
|
+
vals = k.self_attrs.get(e.attr)
|
|
778
|
+
if vals:
|
|
779
|
+
out = []
|
|
780
|
+
for val, _ann, f2 in vals:
|
|
781
|
+
if val is not None:
|
|
782
|
+
out += self.url_values(val, self.Ctx(k.module, f2, k), depth + 1)
|
|
783
|
+
return out[:3]
|
|
784
|
+
return []
|
|
785
|
+
return []
|
|
786
|
+
|
|
787
|
+
@staticmethod
|
|
788
|
+
def literal_params(f) -> dict:
|
|
789
|
+
"""argname -> literal str / int values of a single-name `@pytest.mark.parametrize` (URL parts in f-strings)."""
|
|
790
|
+
out = {}
|
|
791
|
+
for d in f.decorators:
|
|
792
|
+
if not (isinstance(d, ast.Call) and _deco_name(d).split(".")[-1] == "parametrize" and len(d.args) > 1):
|
|
793
|
+
continue
|
|
794
|
+
names = _strs(d.args[0]) or []
|
|
795
|
+
vals = d.args[1]
|
|
796
|
+
if len(names) == 1 and isinstance(vals, (ast.List, ast.Tuple)) and vals.elts and \
|
|
797
|
+
all(isinstance(x, ast.Constant) and isinstance(x.value, (str, int)) and not isinstance(x.value, bool) for x in vals.elts):
|
|
798
|
+
out[names[0]] = [str(x.value) for x in vals.elts[:6]]
|
|
799
|
+
return out
|
|
800
|
+
|
|
801
|
+
def collect_http(self) -> None:
|
|
802
|
+
from .plugin import walk_body
|
|
803
|
+
prog = self.prog
|
|
804
|
+
for f in prog.funcs.values():
|
|
805
|
+
if f.file not in self.test_files:
|
|
806
|
+
continue
|
|
807
|
+
ctx = self.Ctx(f.module, f, f.cls)
|
|
808
|
+
self.param_values = self.literal_params(f)
|
|
809
|
+
for sub in walk_body(f.node):
|
|
810
|
+
if not (isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute)):
|
|
811
|
+
continue
|
|
812
|
+
attr = sub.func.attr
|
|
813
|
+
if attr in HTTP_VERBS:
|
|
814
|
+
verb, ua = HTTP_VERBS[attr], (sub.args[0] if sub.args else _kw(sub, "path") or _kw(sub, "url"))
|
|
815
|
+
elif attr in GENERIC_VERBS and len(sub.args) >= 2 and isinstance(sub.args[0], ast.Constant) \
|
|
816
|
+
and isinstance(sub.args[0].value, str):
|
|
817
|
+
verb, ua = sub.args[0].value.upper(), sub.args[1]
|
|
818
|
+
else:
|
|
819
|
+
continue
|
|
820
|
+
if ua is None or (isinstance(ua, ast.Constant) and not (
|
|
821
|
+
isinstance(ua.value, str) and ua.value.startswith(("/", "http://", "https://")))):
|
|
822
|
+
continue # d.get("key"), cache.get(1): not a request path
|
|
823
|
+
if not self.is_client(sub.func.value, ctx):
|
|
824
|
+
continue
|
|
825
|
+
vals = self.url_values(ua, ctx)
|
|
826
|
+
self.pending.append((f, verb, vals, sub.lineno, f"{_text(sub.func, 40)}", self.request_host(sub, ctx, ua)))
|
|
827
|
+
|
|
828
|
+
@staticmethod
|
|
829
|
+
def _overrides_in(node) -> list[str]:
|
|
830
|
+
"""`app.dependency_overrides[get_current_user] = fake` / `.update({dep: fake})` in a body: the overridden
|
|
831
|
+
dependency names (#59)."""
|
|
832
|
+
from .plugin import dotted
|
|
833
|
+
out = []
|
|
834
|
+
for sub in ast.walk(node):
|
|
835
|
+
keys = []
|
|
836
|
+
if isinstance(sub, ast.Assign):
|
|
837
|
+
for t in sub.targets:
|
|
838
|
+
if isinstance(t, ast.Subscript) and isinstance(t.value, ast.Attribute) and \
|
|
839
|
+
t.value.attr == "dependency_overrides":
|
|
840
|
+
keys.append(t.slice)
|
|
841
|
+
elif isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) and sub.func.attr == "update" and \
|
|
842
|
+
isinstance(sub.func.value, ast.Attribute) and sub.func.value.attr == "dependency_overrides" and \
|
|
843
|
+
sub.args and isinstance(sub.args[0], ast.Dict):
|
|
844
|
+
keys += [k for k in sub.args[0].keys if k is not None]
|
|
845
|
+
for k in keys:
|
|
846
|
+
n = (dotted(k) or "").rsplit(".", 1)[-1]
|
|
847
|
+
if n and n not in out:
|
|
848
|
+
out.append(n)
|
|
849
|
+
return out
|
|
850
|
+
|
|
851
|
+
def overrides_for(self, f) -> list[str]:
|
|
852
|
+
"""Dependency overrides in effect for a test: in the test itself, the fixtures it requests (and theirs, two
|
|
853
|
+
levels, conftest included), and its module's top level."""
|
|
854
|
+
cache = self.__dict__.setdefault("_ovr", {})
|
|
855
|
+
if f.id in cache:
|
|
856
|
+
return cache[f.id]
|
|
857
|
+
out = list(self._overrides_in(f.node))
|
|
858
|
+
chain = self.scope_chain(f.cls, f.module)
|
|
859
|
+
todo, seen = [(n, 0) for n in self.requested(f.node)], set()
|
|
860
|
+
while todo:
|
|
861
|
+
n, d = todo.pop()
|
|
862
|
+
fx = self.lookup_fixture(n, chain)
|
|
863
|
+
if fx is None or fx.id in seen or d > 2:
|
|
864
|
+
continue
|
|
865
|
+
seen.add(fx.id)
|
|
866
|
+
out += [x for x in self._overrides_in(fx.node) if x not in out]
|
|
867
|
+
todo += [(m, d + 1) for m in self.requested(fx.node)]
|
|
868
|
+
for st in getattr(f.module, "tree", ast.Module(body=[], type_ignores=[])).body:
|
|
869
|
+
if not isinstance(st, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
870
|
+
out += [x for x in self._overrides_in(st) if x not in out]
|
|
871
|
+
cache[f.id] = out
|
|
872
|
+
return out
|
|
873
|
+
|
|
874
|
+
def request_host(self, call, ctx, ua) -> tuple | None:
|
|
875
|
+
"""The host a test request names (#59): Flask `subdomain="api"`, `base_url="http://api.example.com"`,
|
|
876
|
+
`headers={"Host": ...}`, or an absolute URL: ("subdomain", "api") / ("host", "api.example.com")."""
|
|
877
|
+
sd = _kw(call, "subdomain")
|
|
878
|
+
v = str_value(self.prog, sd, ctx) if sd is not None else None
|
|
879
|
+
if v:
|
|
880
|
+
return ("subdomain", v)
|
|
881
|
+
hosts = []
|
|
882
|
+
bu = _kw(call, "base_url")
|
|
883
|
+
if bu is not None:
|
|
884
|
+
hosts.append(str_value(self.prog, bu, ctx))
|
|
885
|
+
hd = _kw(call, "headers")
|
|
886
|
+
if isinstance(hd, ast.Dict):
|
|
887
|
+
for k, val in zip(hd.keys, hd.values):
|
|
888
|
+
if isinstance(k, ast.Constant) and str(k.value).lower() == "host":
|
|
889
|
+
hosts.append(str_value(self.prog, val, ctx))
|
|
890
|
+
if isinstance(ua, ast.Constant) and isinstance(ua.value, str) and "://" in ua.value:
|
|
891
|
+
hosts.append(ua.value)
|
|
892
|
+
for h in hosts:
|
|
893
|
+
if h:
|
|
894
|
+
h = re.sub(r"^\w+://", "", h).split("/")[0]
|
|
895
|
+
h = h if h.startswith("[") else h.split(":")[0]
|
|
896
|
+
if h and not LOCAL_HOSTS.match("http://" + h.split("]:")[0] + ("]" if "]:" in h else "")):
|
|
897
|
+
return ("host", h)
|
|
898
|
+
return None
|
|
899
|
+
|
|
900
|
+
@staticmethod
|
|
901
|
+
def nearest_routes(f, cands: list[dict]) -> set:
|
|
902
|
+
"""Same path on several apps (#59): a test that builds its own app and registers routes on it (pallets/flask:
|
|
903
|
+
`@app.route("/")` inside the test, on the `app` fixture) requests those, not the same path another test
|
|
904
|
+
registered. Prefer routes declared in the test function itself, then in its test module."""
|
|
905
|
+
node = getattr(f, "node", None)
|
|
906
|
+
lo, hi = getattr(node, "lineno", 0), getattr(node, "end_lineno", 0) or 0
|
|
907
|
+
same_fn = {r["id"] for r in cands if r.get("file") == f.file and lo <= (r.get("line") or 0) <= hi}
|
|
908
|
+
if same_fn:
|
|
909
|
+
return same_fn
|
|
910
|
+
return {r["id"] for r in cands if r.get("file") == f.file}
|
|
911
|
+
|
|
912
|
+
@staticmethod
|
|
913
|
+
def host_fits(route: dict, rh: tuple | None) -> bool:
|
|
914
|
+
if rh is None:
|
|
915
|
+
return not route.get("host") and not route.get("subdomain")
|
|
916
|
+
kind, v = rh
|
|
917
|
+
if kind == "subdomain":
|
|
918
|
+
return route.get("subdomain") == v or (route.get("host") or "").split(".")[0] == v
|
|
919
|
+
return route.get("host") == v or (bool(route.get("subdomain")) and v.split(".")[0] == route["subdomain"])
|
|
920
|
+
|
|
921
|
+
def match_http(self) -> dict:
|
|
922
|
+
"""TEST_HTTP edges for the requests collect_http() found, against the route nodes in the graph."""
|
|
923
|
+
from ...link import match_endpoint
|
|
924
|
+
b = self.b
|
|
925
|
+
st = defaultdict(int)
|
|
926
|
+
routes, by_name = [], defaultdict(list)
|
|
927
|
+
for nid, n in b.nodes.items():
|
|
928
|
+
a = n.attrs or {}
|
|
929
|
+
if n.kind != "route" or not a.get("uri") or not a.get("method") or a.get("method") == "WS":
|
|
930
|
+
continue
|
|
931
|
+
uris = [("as-declared", a["uri"])]
|
|
932
|
+
if a["uri"].startswith("/{?}") and len(a["uri"]) > 4:
|
|
933
|
+
# a prefix cg could not evaluate (f'{settings.BASE_PATH}...'): tests usually run with it empty
|
|
934
|
+
uris.append(("without unresolved prefix", "/" + a["uri"][4:].lstrip("/")))
|
|
935
|
+
routes.append({"id": nid, "uri": a["uri"], "method": a["method"], "uris": uris, "host": a.get("host"),
|
|
936
|
+
"subdomain": a.get("subdomain"), "file": n.file, "line": n.line or 0})
|
|
937
|
+
if a.get("name"):
|
|
938
|
+
by_name[a["name"]].append(n)
|
|
939
|
+
unmatched = []
|
|
940
|
+
by_id = {r["id"]: r for r in routes}
|
|
941
|
+
for f, verb, vals, line, via, rhost in self.pending:
|
|
942
|
+
st["requests"] += 1
|
|
943
|
+
if not vals:
|
|
944
|
+
st["url_unknown"] += 1
|
|
945
|
+
continue
|
|
946
|
+
known = [(k, v) for k, v in vals if k == "name" or LOCAL_HOSTS.sub("", v).startswith("/")]
|
|
947
|
+
if not known:
|
|
948
|
+
st["url_unknown"] += 1 # `self.client.get(f"{url}?x=1")` with url from a helper, an external URL
|
|
949
|
+
continue
|
|
950
|
+
hit_any = False
|
|
951
|
+
for kind, v in known:
|
|
952
|
+
if kind == "name":
|
|
953
|
+
ns, _, nm = v.rpartition(":")
|
|
954
|
+
cands = [n for n in by_name.get(nm, []) if not ns or (n.attrs.get("namespace") or "") == ns
|
|
955
|
+
or (n.attrs.get("namespace") or "").endswith(":" + ns)]
|
|
956
|
+
if not ns and len({n.attrs.get("namespace") for n in cands}) > 1:
|
|
957
|
+
cands = [n for n in cands if not n.attrs.get("namespace")] or cands
|
|
958
|
+
hits = [n for n in cands if n.attrs.get("method") in (verb, "ANY") or (verb == "HEAD" and n.attrs.get("method") == "GET")]
|
|
959
|
+
if len(hits) > 3:
|
|
960
|
+
hits = []
|
|
961
|
+
conf = EXACT if len(hits) == 1 else HEURISTIC
|
|
962
|
+
for n in hits:
|
|
963
|
+
b.add_edge(f.id, n.id, "TEST_HTTP", f.file, line, conf, via=f"{via}(reverse('{v}'))")
|
|
964
|
+
hit_any = hit_any or bool(hits)
|
|
965
|
+
continue
|
|
966
|
+
path = LOCAL_HOSTS.sub("", v.split("?")[0].split("#")[0])
|
|
967
|
+
if not path.startswith("/"):
|
|
968
|
+
continue # `client.get("key")` on something that is not an HTTP test client, or an external URL
|
|
969
|
+
if path == "/": # the site root: a literal in-process request (the client matcher skips "/")
|
|
970
|
+
res = {"matched": [{"route": r["id"], "confidence": EXACT} for r in routes if r["uri"] in ("/", "")
|
|
971
|
+
and (r["method"] in (verb, "ANY") or (verb == "HEAD" and r["method"] == "GET"))]}
|
|
972
|
+
else:
|
|
973
|
+
res = match_endpoint(verb, path, routes, "api") if routes else {"matched": []}
|
|
974
|
+
if not res["matched"]:
|
|
975
|
+
# a test that registers an all-parameter rule on its own app and requests it (pallets/flask:
|
|
976
|
+
# `@app.route("/<list:args>")` + `client.get("/1,2,3")`, `/<lang_code>/` + `/de/`): the
|
|
977
|
+
# one-literal-segment rule is waived for routes declared in the same test function (#59)
|
|
978
|
+
from ...link import match_path
|
|
979
|
+
own = self.nearest_routes(f, routes)
|
|
980
|
+
loc = [r for r in routes if r["id"] in own and r.get("file") == f.file
|
|
981
|
+
and (r["method"] in (verb, "ANY") or (verb == "HEAD" and r["method"] == "GET"))
|
|
982
|
+
and getattr(f.node, "lineno", 0) <= (r.get("line") or 0) <= (getattr(f.node, "end_lineno", 0) or 0)
|
|
983
|
+
and match_path(path, r["uri"])[0]]
|
|
984
|
+
if loc:
|
|
985
|
+
st["own_param_routes"] += 1
|
|
986
|
+
res = {"matched": [{"route": r["id"], "confidence": HEURISTIC} for r in loc[:3]]}
|
|
987
|
+
fit = [mm for mm in res["matched"] if self.host_fits(by_id.get(mm["route"], {}), rhost)]
|
|
988
|
+
if not fit and rhost is not None: # a host no host-bound route serves: the default-host routes
|
|
989
|
+
fit = [mm for mm in res["matched"] if self.host_fits(by_id.get(mm["route"], {}), None)]
|
|
990
|
+
if fit and len(fit) < len(res["matched"]): # same path on several hosts: the request's host
|
|
991
|
+
st["host_narrowed"] += 1
|
|
992
|
+
res["matched"] = fit
|
|
993
|
+
if len(res["matched"]) > 1:
|
|
994
|
+
near = self.nearest_routes(f, [by_id.get(mm["route"], {}) for mm in res["matched"]])
|
|
995
|
+
if near and len(near) < len(res["matched"]):
|
|
996
|
+
st["app_narrowed"] += 1
|
|
997
|
+
res["matched"] = [mm for mm in res["matched"] if mm["route"] in near]
|
|
998
|
+
if len(res["matched"]) > 3 and all(mm["confidence"] == HEURISTIC for mm in res["matched"]):
|
|
999
|
+
res["matched"] = []
|
|
1000
|
+
ovr = self.overrides_for(f) if res["matched"] else []
|
|
1001
|
+
for mm in res["matched"]:
|
|
1002
|
+
b.add_edge(f.id, mm["route"], "TEST_HTTP", f.file, line, mm["confidence"], via=via, path=path,
|
|
1003
|
+
**({"dependency_overrides": ovr} if ovr else {}))
|
|
1004
|
+
if ovr:
|
|
1005
|
+
st["with_dependency_overrides"] += 1
|
|
1006
|
+
hit_any = hit_any or bool(res["matched"])
|
|
1007
|
+
if hit_any:
|
|
1008
|
+
st["matched"] += 1
|
|
1009
|
+
else:
|
|
1010
|
+
st["unmatched"] += 1
|
|
1011
|
+
if len(unmatched) < 5:
|
|
1012
|
+
unmatched.append(f"{f.file}:{line} {verb} {vals[0][1]}")
|
|
1013
|
+
out = dict(st)
|
|
1014
|
+
if not routes and st["requests"]:
|
|
1015
|
+
out["note"] = "no route nodes in the graph (web framework without a cg plugin): requests stay unlinked"
|
|
1016
|
+
if unmatched:
|
|
1017
|
+
out["unmatched_samples"] = unmatched
|
|
1018
|
+
self.pending = []
|
|
1019
|
+
return out
|
|
1020
|
+
|
|
1021
|
+
# ------------------------------------------------------------------ driver
|
|
1022
|
+
def index(self) -> dict:
|
|
1023
|
+
self.classify()
|
|
1024
|
+
if not self.test_files:
|
|
1025
|
+
return {}
|
|
1026
|
+
self.collect_fixtures()
|
|
1027
|
+
marked = self.mark()
|
|
1028
|
+
res = self.cases()
|
|
1029
|
+
fx_edges = self.fixture_edges()
|
|
1030
|
+
self.collect_http()
|
|
1031
|
+
why = defaultdict(int)
|
|
1032
|
+
for v in self.test_files.values():
|
|
1033
|
+
why[v] += 1
|
|
1034
|
+
st = {"test_files": dict(why), "test_code_nodes": marked, **res, "fixtures": len(self.fixture_info),
|
|
1035
|
+
"fixture_chain_edges": fx_edges}
|
|
1036
|
+
cfgs = [d.as_dict() for d in self.discs if d.source]
|
|
1037
|
+
if cfgs:
|
|
1038
|
+
st["pytest_config"] = cfgs
|
|
1039
|
+
self.st = st
|
|
1040
|
+
return st
|