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.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. 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