fc-data 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- datasmith/__init__.py +330 -0
- datasmith/__init__.pyi +194 -0
- datasmith/agents/__init__.py +31 -0
- datasmith/agents/classifiers.py +272 -0
- datasmith/agents/codex.py +25 -0
- datasmith/agents/config.py +108 -0
- datasmith/agents/extractors.py +197 -0
- datasmith/agents/installed/README.md +52 -0
- datasmith/agents/installed/__init__.py +22 -0
- datasmith/agents/installed/base.py +240 -0
- datasmith/agents/installed/claude.py +134 -0
- datasmith/agents/installed/codex.py +91 -0
- datasmith/agents/installed/gemini.py +118 -0
- datasmith/agents/installed/none.py +27 -0
- datasmith/agents/sandbox.py +547 -0
- datasmith/agents/synthesizer.py +439 -0
- datasmith/agents/templates/AGENTS.md.j2 +150 -0
- datasmith/agents/templates/sandbox_verify.py +428 -0
- datasmith/docker/__init__.py +31 -0
- datasmith/docker/context.py +112 -0
- datasmith/docker/images.py +158 -0
- datasmith/docker/publish.py +56 -0
- datasmith/docker/templates/Dockerfile.base +26 -0
- datasmith/docker/templates/Dockerfile.pr +42 -0
- datasmith/docker/templates/Dockerfile.repo +11 -0
- datasmith/docker/templates/docker_build_base.sh +780 -0
- datasmith/docker/templates/docker_build_env.sh +309 -0
- datasmith/docker/templates/docker_build_final.sh +106 -0
- datasmith/docker/templates/docker_build_pkg.sh +99 -0
- datasmith/docker/templates/docker_build_run.sh +124 -0
- datasmith/docker/templates/entrypoint.sh +62 -0
- datasmith/docker/templates/parser.py +1405 -0
- datasmith/docker/templates/profile.sh +199 -0
- datasmith/docker/templates/pytest_runner.py +692 -0
- datasmith/docker/templates/run-tests.sh +197 -0
- datasmith/docker/verifiers.py +131 -0
- datasmith/filters.py +154 -0
- datasmith/github/__init__.py +22 -0
- datasmith/github/client.py +333 -0
- datasmith/github/hooks.py +50 -0
- datasmith/github/links.py +110 -0
- datasmith/github/models.py +206 -0
- datasmith/github/render.py +173 -0
- datasmith/github/search.py +66 -0
- datasmith/github/templates/comment.md.j2 +5 -0
- datasmith/github/templates/final.md.j2 +66 -0
- datasmith/github/templates/issues.md.j2 +21 -0
- datasmith/github/templates/repo.md.j2 +1 -0
- datasmith/preflight.py +162 -0
- datasmith/publish/__init__.py +13 -0
- datasmith/publish/huggingface.py +104 -0
- datasmith/publish/pipeline.py +60 -0
- datasmith/publish/records.py +91 -0
- datasmith/py.typed +1 -0
- datasmith/resolution/__init__.py +14 -0
- datasmith/resolution/blocklist.py +145 -0
- datasmith/resolution/cache.py +120 -0
- datasmith/resolution/constants.py +277 -0
- datasmith/resolution/dependency_resolver.py +174 -0
- datasmith/resolution/git_utils.py +378 -0
- datasmith/resolution/import_analyzer.py +66 -0
- datasmith/resolution/metadata_parser.py +412 -0
- datasmith/resolution/models.py +41 -0
- datasmith/resolution/orchestrator.py +522 -0
- datasmith/resolution/package_filters.py +312 -0
- datasmith/resolution/python_manager.py +110 -0
- datasmith/runners/__init__.py +15 -0
- datasmith/runners/base.py +112 -0
- datasmith/runners/classify_prs.py +48 -0
- datasmith/runners/render_problems.py +113 -0
- datasmith/runners/resolve_packages.py +66 -0
- datasmith/runners/scrape_commits.py +166 -0
- datasmith/runners/scrape_repos.py +44 -0
- datasmith/runners/synthesize_images.py +310 -0
- datasmith/update/__init__.py +5 -0
- datasmith/update/cli.py +169 -0
- datasmith/update/offline.py +173 -0
- datasmith/update/pipeline.py +497 -0
- datasmith/utils/__init__.py +18 -0
- datasmith/utils/core.py +67 -0
- datasmith/utils/db.py +156 -0
- datasmith/utils/tokens.py +65 -0
- fc_data-0.2.0.dist-info/METADATA +441 -0
- fc_data-0.2.0.dist-info/RECORD +87 -0
- fc_data-0.2.0.dist-info/WHEEL +4 -0
- fc_data-0.2.0.dist-info/entry_points.txt +2 -0
- fc_data-0.2.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from glob import glob
|
|
10
|
+
|
|
11
|
+
# --------------------------- Utilities ---------------------------
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _which(cmd):
|
|
15
|
+
"""Return True if a command exists on PATH."""
|
|
16
|
+
from shutil import which
|
|
17
|
+
|
|
18
|
+
return which(cmd) is not None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run(cmd, cwd=None):
|
|
22
|
+
"""Run a command and return (exit_code, stdout, stderr)."""
|
|
23
|
+
try:
|
|
24
|
+
p = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False)
|
|
25
|
+
out, err = p.communicate()
|
|
26
|
+
return p.returncode, out.decode("utf-8", errors="replace"), err.decode("utf-8", errors="replace")
|
|
27
|
+
except Exception as e:
|
|
28
|
+
return 1, "", "Failed to run %r: %s" % (cmd, e)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def detect_repo_root():
|
|
32
|
+
"""Best-effort Git repo root, else current working directory."""
|
|
33
|
+
if _which("git"):
|
|
34
|
+
code, out, _ = _run(["git", "rev-parse", "--show-toplevel"])
|
|
35
|
+
if code == 0:
|
|
36
|
+
root = out.strip()
|
|
37
|
+
if root:
|
|
38
|
+
return root
|
|
39
|
+
return os.getcwd()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_changed_files(
|
|
43
|
+
base_ref="origin/main",
|
|
44
|
+
include_committed=True,
|
|
45
|
+
include_unstaged=True,
|
|
46
|
+
include_staged=False,
|
|
47
|
+
include_untracked=False,
|
|
48
|
+
repo_root=None,
|
|
49
|
+
):
|
|
50
|
+
"""
|
|
51
|
+
Return a sorted list of changed file paths relative to repo root.
|
|
52
|
+
|
|
53
|
+
- committed: diff vs MERGE-BASE(base_ref, HEAD) ... HEAD (triple dot).
|
|
54
|
+
- unstaged: working tree changes vs index (git diff --name-only).
|
|
55
|
+
- staged: diff --cached vs HEAD.
|
|
56
|
+
- untracked: untracked files (git ls-files --others --exclude-standard).
|
|
57
|
+
"""
|
|
58
|
+
repo_root = repo_root or detect_repo_root()
|
|
59
|
+
changed = set()
|
|
60
|
+
|
|
61
|
+
if not _which("git"):
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
def git(args):
|
|
65
|
+
return _run(["git"] + args, cwd=repo_root)
|
|
66
|
+
|
|
67
|
+
if include_committed:
|
|
68
|
+
code, out, _ = git(["diff", "--name-only", f"{base_ref}...HEAD"])
|
|
69
|
+
if code == 0:
|
|
70
|
+
changed.update([ln.strip() for ln in out.splitlines() if ln.strip()])
|
|
71
|
+
|
|
72
|
+
if include_unstaged:
|
|
73
|
+
code, out, _ = git(["diff", "--name-only"])
|
|
74
|
+
if code == 0:
|
|
75
|
+
changed.update([ln.strip() for ln in out.splitlines() if ln.strip()])
|
|
76
|
+
|
|
77
|
+
if include_staged:
|
|
78
|
+
code, out, _ = git(["diff", "--name-only", "--cached"])
|
|
79
|
+
if code == 0:
|
|
80
|
+
changed.update([ln.strip() for ln in out.splitlines() if ln.strip()])
|
|
81
|
+
|
|
82
|
+
if include_untracked:
|
|
83
|
+
code, out, _ = git(["ls-files", "--others", "--exclude-standard"])
|
|
84
|
+
if code == 0:
|
|
85
|
+
changed.update([ln.strip() for ln in out.splitlines() if ln.strip()])
|
|
86
|
+
|
|
87
|
+
normalized = []
|
|
88
|
+
for path in sorted(changed):
|
|
89
|
+
norm = path.replace("\\", "/").strip()
|
|
90
|
+
if norm:
|
|
91
|
+
normalized.append(norm)
|
|
92
|
+
return normalized
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_test_file(path):
|
|
96
|
+
"""Heuristic: does a filename look like a pytest test file?"""
|
|
97
|
+
fname = os.path.basename(path)
|
|
98
|
+
return (fname.startswith("test_") and fname.endswith(".py")) or (fname.endswith("_test.py"))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def python_file(path):
|
|
102
|
+
return path.endswith(".py")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def strip_src_prefix(path):
|
|
106
|
+
"""
|
|
107
|
+
If path begins with common source roots like 'src/' or 'lib/' return path without it.
|
|
108
|
+
Else return as-is.
|
|
109
|
+
"""
|
|
110
|
+
for prefix in ("src/", "lib/"):
|
|
111
|
+
if path.startswith(prefix):
|
|
112
|
+
return path[len(prefix) :]
|
|
113
|
+
return path
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def candidate_test_paths_for_source(src_path, search_roots):
|
|
117
|
+
"""
|
|
118
|
+
Given a source file like 'pkg/foo.py' return likely test file candidates:
|
|
119
|
+
tests/pkg/test_foo.py
|
|
120
|
+
tests/test_foo.py
|
|
121
|
+
**/tests/**/test_foo.py
|
|
122
|
+
Also check sibling test files in the same directory:
|
|
123
|
+
pkg/test_foo.py
|
|
124
|
+
"""
|
|
125
|
+
src_rel = strip_src_prefix(src_path)
|
|
126
|
+
src_dir = os.path.dirname(src_rel).replace("\\", "/")
|
|
127
|
+
mod = os.path.splitext(os.path.basename(src_rel))[0]
|
|
128
|
+
|
|
129
|
+
candidates = set()
|
|
130
|
+
|
|
131
|
+
patterns = [
|
|
132
|
+
# top-level tests dirs
|
|
133
|
+
"tests/%s/test_%s.py" % (src_dir, mod) if src_dir else "tests/test_%s.py" % mod,
|
|
134
|
+
"tests/**/test_%s.py" % mod,
|
|
135
|
+
"tests/%s/%s_test.py" % (src_dir, mod) if src_dir else "tests/%s_test.py" % mod,
|
|
136
|
+
"tests/**/%s_test.py" % mod,
|
|
137
|
+
# nested tests dirs anywhere under repo (e.g., sklearn/metrics/tests/test_pairwise.py)
|
|
138
|
+
"**/tests/**/test_%s.py" % mod,
|
|
139
|
+
"**/tests/**/%s_test.py" % mod,
|
|
140
|
+
# sibling tests near source
|
|
141
|
+
"%s/test_%s.py" % (os.path.dirname(src_path), mod),
|
|
142
|
+
"%s/%s_test.py" % (os.path.dirname(src_path), mod),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
expanded = set()
|
|
146
|
+
for pat in patterns:
|
|
147
|
+
for root in search_roots:
|
|
148
|
+
g = glob(os.path.join(root, pat).replace("\\", "/"), recursive=True)
|
|
149
|
+
for p in g:
|
|
150
|
+
p = os.path.relpath(p, root).replace("\\", "/")
|
|
151
|
+
expanded.add(p)
|
|
152
|
+
|
|
153
|
+
candidates |= expanded
|
|
154
|
+
existing = [p for p in candidates if os.path.isfile(os.path.join(search_roots[0], p))]
|
|
155
|
+
return sorted(set(existing))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def discover_test_files_from_changed(changed_files, repo_root):
|
|
159
|
+
"""
|
|
160
|
+
Return (selected_test_files, mapping) where:
|
|
161
|
+
selected_test_files: list of test file paths (relative to repo root)
|
|
162
|
+
mapping: dict of changed_file -> list of matched test files
|
|
163
|
+
"""
|
|
164
|
+
selected = set()
|
|
165
|
+
mapping = {}
|
|
166
|
+
search_roots = [repo_root]
|
|
167
|
+
|
|
168
|
+
for path in changed_files:
|
|
169
|
+
rel = path.replace("\\", "/")
|
|
170
|
+
abs_path = os.path.join(repo_root, rel)
|
|
171
|
+
|
|
172
|
+
if os.path.isdir(abs_path):
|
|
173
|
+
tests_in_dir = []
|
|
174
|
+
for pat in ("**/test_*.py", "**/*_test.py"):
|
|
175
|
+
tests_in_dir += glob(os.path.join(abs_path, pat), recursive=True)
|
|
176
|
+
tests_in_dir = [os.path.relpath(p, repo_root).replace("\\", "/") for p in tests_in_dir]
|
|
177
|
+
if tests_in_dir:
|
|
178
|
+
mapping[rel] = sorted(set(tests_in_dir))
|
|
179
|
+
selected.update(mapping[rel])
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
if is_test_file(rel):
|
|
183
|
+
mapping.setdefault(rel, [])
|
|
184
|
+
mapping[rel].append(rel)
|
|
185
|
+
selected.add(rel)
|
|
186
|
+
elif python_file(rel):
|
|
187
|
+
tests = candidate_test_paths_for_source(rel, search_roots)
|
|
188
|
+
if tests:
|
|
189
|
+
mapping[rel] = tests
|
|
190
|
+
selected.update(tests)
|
|
191
|
+
|
|
192
|
+
return sorted(selected), mapping
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# --------------------------- Pytest Integration ---------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class _ResultsPlugin:
|
|
199
|
+
"""
|
|
200
|
+
Pytest plugin to collect structured results.
|
|
201
|
+
Works with pytest 4.x ... 8.x. We dynamically attach the proper
|
|
202
|
+
warnings hook ('pytest_warning_recorded' on >=7, otherwise
|
|
203
|
+
'pytest_warning_captured') at runtime to avoid Pluggy validation errors.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def __init__(self):
|
|
207
|
+
self.results = {
|
|
208
|
+
"start_time": time.time(),
|
|
209
|
+
"duration": None,
|
|
210
|
+
"exit_code": None,
|
|
211
|
+
"summary": {
|
|
212
|
+
"total": 0,
|
|
213
|
+
"passed": 0,
|
|
214
|
+
"failed": 0,
|
|
215
|
+
"skipped": 0,
|
|
216
|
+
"xfailed": 0,
|
|
217
|
+
"xpassed": 0,
|
|
218
|
+
"error": 0,
|
|
219
|
+
"rerun": 0,
|
|
220
|
+
"warnings": 0,
|
|
221
|
+
},
|
|
222
|
+
"tests": [],
|
|
223
|
+
"errors": [],
|
|
224
|
+
"warnings": [],
|
|
225
|
+
"pytest_version": None,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
def pytest_sessionstart(self, session):
|
|
229
|
+
try:
|
|
230
|
+
import pytest
|
|
231
|
+
|
|
232
|
+
self.results["pytest_version"] = getattr(pytest, "__version__", None)
|
|
233
|
+
except Exception:
|
|
234
|
+
self.results["pytest_version"] = None
|
|
235
|
+
|
|
236
|
+
def pytest_runtest_logreport(self, report):
|
|
237
|
+
if report.when not in ("setup", "call", "teardown"):
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
if report.when == "setup" and report.failed:
|
|
241
|
+
outcome = "error"
|
|
242
|
+
elif report.when == "call":
|
|
243
|
+
outcome = report.outcome
|
|
244
|
+
elif report.when == "teardown" and report.failed:
|
|
245
|
+
outcome = "error"
|
|
246
|
+
else:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
entry = {
|
|
250
|
+
"nodeid": getattr(report, "nodeid", None),
|
|
251
|
+
"when": report.when,
|
|
252
|
+
"outcome": outcome,
|
|
253
|
+
"duration": getattr(report, "duration", None),
|
|
254
|
+
"longrepr": None,
|
|
255
|
+
"capstdout": getattr(report, "capstdout", None),
|
|
256
|
+
"capstderr": getattr(report, "capstderr", None),
|
|
257
|
+
"location": getattr(report, "location", None),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
if hasattr(report, "longreprtext") and report.longreprtext:
|
|
262
|
+
entry["longrepr"] = report.longreprtext
|
|
263
|
+
elif getattr(report, "longrepr", None) is not None:
|
|
264
|
+
entry["longrepr"] = str(report.longrepr)
|
|
265
|
+
except Exception:
|
|
266
|
+
entry["longrepr"] = None
|
|
267
|
+
|
|
268
|
+
self.results["tests"].append(entry)
|
|
269
|
+
|
|
270
|
+
if (
|
|
271
|
+
report.when == "call"
|
|
272
|
+
or (report.when == "setup" and report.failed)
|
|
273
|
+
or (report.when == "teardown" and report.failed)
|
|
274
|
+
):
|
|
275
|
+
s = self.results["summary"]
|
|
276
|
+
s["total"] += 1
|
|
277
|
+
if outcome == "passed":
|
|
278
|
+
s["passed"] += 1
|
|
279
|
+
elif outcome == "failed":
|
|
280
|
+
s["failed"] += 1
|
|
281
|
+
elif outcome == "skipped":
|
|
282
|
+
s["skipped"] += 1
|
|
283
|
+
elif outcome == "xfailed":
|
|
284
|
+
s["xfailed"] += 1
|
|
285
|
+
elif outcome == "xpassed":
|
|
286
|
+
s["xpassed"] += 1
|
|
287
|
+
elif outcome == "error":
|
|
288
|
+
s["error"] += 1
|
|
289
|
+
|
|
290
|
+
def pytest_sessionfinish(self, session, exitstatus):
|
|
291
|
+
self.results["exit_code"] = int(exitstatus)
|
|
292
|
+
self.results["duration"] = time.time() - self.results["start_time"]
|
|
293
|
+
|
|
294
|
+
def pytest_collectreport(self, report):
|
|
295
|
+
if report.failed:
|
|
296
|
+
entry = {
|
|
297
|
+
"nodeid": getattr(report, "nodeid", None),
|
|
298
|
+
"longrepr": None,
|
|
299
|
+
}
|
|
300
|
+
try:
|
|
301
|
+
if hasattr(report, "longreprtext") and report.longreprtext:
|
|
302
|
+
entry["longrepr"] = report.longreprtext
|
|
303
|
+
elif getattr(report, "longrepr", None) is not None:
|
|
304
|
+
entry["longrepr"] = str(report.longrepr)
|
|
305
|
+
except Exception:
|
|
306
|
+
entry["longrepr"] = None
|
|
307
|
+
self.results["errors"].append(entry)
|
|
308
|
+
self.results["summary"]["error"] += 1
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _attach_warning_hook(plugin, pytest_module):
|
|
312
|
+
"""
|
|
313
|
+
Attach the appropriate warnings hook to 'plugin' depending on pytest version.
|
|
314
|
+
- pytest >= 7: use pytest_warning_recorded(warning_message, when, nodeid, location)
|
|
315
|
+
- pytest < 7: use pytest_warning_captured(warning_message, when, item, location)
|
|
316
|
+
We add the attribute dynamically BEFORE registering the plugin.
|
|
317
|
+
"""
|
|
318
|
+
ver = getattr(pytest_module, "__version__", "0")
|
|
319
|
+
try:
|
|
320
|
+
major = int(ver.split(".", 1)[0])
|
|
321
|
+
except Exception:
|
|
322
|
+
major = 0
|
|
323
|
+
|
|
324
|
+
if major >= 7:
|
|
325
|
+
|
|
326
|
+
def _warning_recorded(warning_message, when, nodeid, location):
|
|
327
|
+
try:
|
|
328
|
+
message = str(getattr(warning_message, "message", warning_message))
|
|
329
|
+
except Exception:
|
|
330
|
+
message = repr(warning_message)
|
|
331
|
+
entry = {
|
|
332
|
+
"message": message,
|
|
333
|
+
"when": when,
|
|
334
|
+
"location": location,
|
|
335
|
+
"nodeid": nodeid,
|
|
336
|
+
}
|
|
337
|
+
plugin.results["warnings"].append(entry)
|
|
338
|
+
|
|
339
|
+
plugin.pytest_warning_recorded = _warning_recorded
|
|
340
|
+
else:
|
|
341
|
+
|
|
342
|
+
def _warning_captured(warning_message, when, item, location):
|
|
343
|
+
try:
|
|
344
|
+
message = str(getattr(warning_message, "message", warning_message))
|
|
345
|
+
except Exception:
|
|
346
|
+
message = repr(warning_message)
|
|
347
|
+
entry = {
|
|
348
|
+
"message": message,
|
|
349
|
+
"when": when,
|
|
350
|
+
"location": location,
|
|
351
|
+
"nodeid": getattr(item, "nodeid", None) if item is not None else None,
|
|
352
|
+
}
|
|
353
|
+
plugin.results["warnings"].append(entry)
|
|
354
|
+
|
|
355
|
+
plugin.pytest_warning_captured = _warning_captured
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def run_pytest_and_collect(test_paths, extra_args=None, cwd=None):
|
|
359
|
+
import pytest
|
|
360
|
+
|
|
361
|
+
if isinstance(extra_args, str):
|
|
362
|
+
extra_args = shlex.split(extra_args)
|
|
363
|
+
elif extra_args is None:
|
|
364
|
+
extra_args = []
|
|
365
|
+
|
|
366
|
+
# Convert to absolute paths to avoid collecting the whole tree accidentally
|
|
367
|
+
base = cwd or os.getcwd()
|
|
368
|
+
unique_paths = []
|
|
369
|
+
for p in test_paths:
|
|
370
|
+
ap = os.path.abspath(os.path.join(base, p))
|
|
371
|
+
if os.path.exists(ap):
|
|
372
|
+
unique_paths.append(ap)
|
|
373
|
+
|
|
374
|
+
plugin = _ResultsPlugin()
|
|
375
|
+
_attach_warning_hook(plugin, pytest)
|
|
376
|
+
|
|
377
|
+
args = unique_paths + list(extra_args)
|
|
378
|
+
exit_code = pytest.main(args=args, plugins=[plugin])
|
|
379
|
+
plugin.results["exit_code"] = int(exit_code)
|
|
380
|
+
plugin.results["selected_paths"] = unique_paths
|
|
381
|
+
return plugin.results
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# --------------------------- High-level API ---------------------------
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def run_changed_tests(
|
|
388
|
+
base_ref="origin/main",
|
|
389
|
+
include_committed=True,
|
|
390
|
+
include_unstaged=True,
|
|
391
|
+
include_staged=False,
|
|
392
|
+
include_untracked=False,
|
|
393
|
+
extra_pytest_args=None,
|
|
394
|
+
fallback=None,
|
|
395
|
+
repo_root=None,
|
|
396
|
+
):
|
|
397
|
+
"""
|
|
398
|
+
High-level helper:
|
|
399
|
+
1) Identify changed files.
|
|
400
|
+
2) Map to test files.
|
|
401
|
+
3) Run pytest on those tests and return structured results.
|
|
402
|
+
"""
|
|
403
|
+
repo_root = repo_root or detect_repo_root()
|
|
404
|
+
changed = get_changed_files(
|
|
405
|
+
base_ref=base_ref,
|
|
406
|
+
include_committed=include_committed,
|
|
407
|
+
include_unstaged=include_unstaged,
|
|
408
|
+
include_staged=include_staged,
|
|
409
|
+
include_untracked=include_untracked,
|
|
410
|
+
repo_root=repo_root,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
selected_tests, mapping = discover_test_files_from_changed(changed, repo_root=repo_root)
|
|
414
|
+
|
|
415
|
+
result = {
|
|
416
|
+
"repo_root": repo_root,
|
|
417
|
+
"base_ref": base_ref,
|
|
418
|
+
"changed_files": changed,
|
|
419
|
+
"selected_tests": selected_tests,
|
|
420
|
+
"mapping": mapping,
|
|
421
|
+
"strategy": "changed-files->tests",
|
|
422
|
+
"note": None,
|
|
423
|
+
"results": None,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if selected_tests:
|
|
427
|
+
results = run_pytest_and_collect(selected_tests, extra_args=extra_pytest_args, cwd=repo_root)
|
|
428
|
+
result["results"] = results
|
|
429
|
+
return result
|
|
430
|
+
|
|
431
|
+
if fallback == "last-failed":
|
|
432
|
+
results = run_pytest_and_collect(
|
|
433
|
+
[],
|
|
434
|
+
extra_args=["--last-failed"]
|
|
435
|
+
+ (shlex.split(extra_pytest_args) if isinstance(extra_pytest_args, str) else (extra_pytest_args or [])),
|
|
436
|
+
cwd=repo_root,
|
|
437
|
+
)
|
|
438
|
+
result["strategy"] = "fallback:last-failed"
|
|
439
|
+
result["note"] = "No tests mapped from changes; ran --last-failed."
|
|
440
|
+
result["results"] = results
|
|
441
|
+
return result
|
|
442
|
+
elif fallback == "all":
|
|
443
|
+
results = run_pytest_and_collect([], extra_args=extra_pytest_args, cwd=repo_root)
|
|
444
|
+
result["strategy"] = "fallback:all"
|
|
445
|
+
result["note"] = "No tests mapped from changes; ran entire suite."
|
|
446
|
+
result["results"] = results
|
|
447
|
+
return result
|
|
448
|
+
|
|
449
|
+
result["note"] = "No tests matched the changed files. Consider --fallback last-failed or --fallback all."
|
|
450
|
+
result["results"] = {
|
|
451
|
+
"exit_code": 5,
|
|
452
|
+
"summary": {
|
|
453
|
+
"total": 0,
|
|
454
|
+
"passed": 0,
|
|
455
|
+
"failed": 0,
|
|
456
|
+
"skipped": 0,
|
|
457
|
+
"xfailed": 0,
|
|
458
|
+
"xpassed": 0,
|
|
459
|
+
"error": 0,
|
|
460
|
+
"rerun": 0,
|
|
461
|
+
"warnings": 0,
|
|
462
|
+
},
|
|
463
|
+
"tests": [],
|
|
464
|
+
"warnings": [],
|
|
465
|
+
"errors": [],
|
|
466
|
+
"selected_paths": [],
|
|
467
|
+
"duration": 0.0,
|
|
468
|
+
"pytest_version": None,
|
|
469
|
+
}
|
|
470
|
+
return result
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# --------------------------- CLI ---------------------------
|
|
474
|
+
def parse_args():
|
|
475
|
+
parser = argparse.ArgumentParser(
|
|
476
|
+
description="Run pytest only for changed files (committed/unstaged/staged/untracked)."
|
|
477
|
+
)
|
|
478
|
+
parser.add_argument(
|
|
479
|
+
"--all",
|
|
480
|
+
dest="run_all",
|
|
481
|
+
action="store_true",
|
|
482
|
+
help="Bypass git change detection and run the entire pytest suite.",
|
|
483
|
+
)
|
|
484
|
+
parser.add_argument(
|
|
485
|
+
"--base", dest="base_ref", default="origin/main", help="Base ref for committed diff (default: origin/main)."
|
|
486
|
+
)
|
|
487
|
+
parser.add_argument(
|
|
488
|
+
"--no-committed", dest="include_committed", action="store_false", help="Exclude committed changes vs base."
|
|
489
|
+
)
|
|
490
|
+
parser.add_argument(
|
|
491
|
+
"--no-unstaged",
|
|
492
|
+
dest="include_unstaged",
|
|
493
|
+
action="store_false",
|
|
494
|
+
help="Exclude unstaged working tree changes (default: include).",
|
|
495
|
+
)
|
|
496
|
+
parser.add_argument(
|
|
497
|
+
"--staged", dest="include_staged", action="store_true", help="Include staged changes (default: exclude)."
|
|
498
|
+
)
|
|
499
|
+
parser.add_argument(
|
|
500
|
+
"--untracked", dest="include_untracked", action="store_true", help="Include untracked files (default: exclude)."
|
|
501
|
+
)
|
|
502
|
+
parser.add_argument(
|
|
503
|
+
"--only",
|
|
504
|
+
choices=["src", "test", "all"],
|
|
505
|
+
default="all",
|
|
506
|
+
help="Limit to only source files, only test files, or all (default).",
|
|
507
|
+
)
|
|
508
|
+
parser.add_argument("--json", dest="json_out", default=None, help="Write full results JSON to this path.")
|
|
509
|
+
parser.add_argument(
|
|
510
|
+
"--extra-args", dest="extra_args", default="", help="Extra pytest args, e.g. \"-q -k 'foo and not slow'\"."
|
|
511
|
+
)
|
|
512
|
+
parser.add_argument(
|
|
513
|
+
"--fallback",
|
|
514
|
+
choices=[None, "last-failed", "all"],
|
|
515
|
+
default=None,
|
|
516
|
+
help="What to run if no tests are mapped: None, last-failed, or all.",
|
|
517
|
+
)
|
|
518
|
+
parser.add_argument("--root", dest="repo_root", default=None, help="Force a repo root path (default: auto-detect).")
|
|
519
|
+
parser.add_argument("--print-mapping", action="store_true", help="Print changed->tests mapping.")
|
|
520
|
+
parser.add_argument("--debug", action="store_true", help="Verbose debug output.")
|
|
521
|
+
|
|
522
|
+
args = parser.parse_args()
|
|
523
|
+
return args
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def main(args) -> dict:
|
|
527
|
+
repo_root = args.repo_root or detect_repo_root()
|
|
528
|
+
res = {
|
|
529
|
+
"exit_code": 5,
|
|
530
|
+
"summary": {
|
|
531
|
+
"total": 0,
|
|
532
|
+
"passed": 0,
|
|
533
|
+
"failed": 0,
|
|
534
|
+
"skipped": 0,
|
|
535
|
+
"xfailed": 0,
|
|
536
|
+
"xpassed": 0,
|
|
537
|
+
"error": 0,
|
|
538
|
+
"rerun": 0,
|
|
539
|
+
"warnings": 0,
|
|
540
|
+
},
|
|
541
|
+
"tests": [],
|
|
542
|
+
"warnings": [],
|
|
543
|
+
"errors": [],
|
|
544
|
+
"selected_paths": [],
|
|
545
|
+
"duration": 0.0,
|
|
546
|
+
"pytest_version": None,
|
|
547
|
+
}
|
|
548
|
+
out = {
|
|
549
|
+
"repo_root": repo_root,
|
|
550
|
+
"base_ref": args.base_ref,
|
|
551
|
+
"changed_files": [],
|
|
552
|
+
"selected_tests": [],
|
|
553
|
+
"mapping": {},
|
|
554
|
+
"strategy": None,
|
|
555
|
+
"note": None,
|
|
556
|
+
"results": res,
|
|
557
|
+
}
|
|
558
|
+
if args.run_all:
|
|
559
|
+
try:
|
|
560
|
+
res = run_pytest_and_collect([], extra_args=args.extra_args, cwd=repo_root)
|
|
561
|
+
strategy = "all"
|
|
562
|
+
note = "Bypassed git change detection; ran entire suite."
|
|
563
|
+
out = {
|
|
564
|
+
"repo_root": repo_root,
|
|
565
|
+
"base_ref": args.base_ref,
|
|
566
|
+
"changed_files": [],
|
|
567
|
+
"selected_tests": [],
|
|
568
|
+
"mapping": {},
|
|
569
|
+
"strategy": strategy,
|
|
570
|
+
"note": note,
|
|
571
|
+
"results": res,
|
|
572
|
+
}
|
|
573
|
+
return out
|
|
574
|
+
except SystemExit:
|
|
575
|
+
return out
|
|
576
|
+
except Exception:
|
|
577
|
+
return out
|
|
578
|
+
|
|
579
|
+
changed = get_changed_files(
|
|
580
|
+
base_ref=args.base_ref,
|
|
581
|
+
include_committed=args.include_committed,
|
|
582
|
+
include_unstaged=args.include_unstaged,
|
|
583
|
+
include_staged=args.include_staged,
|
|
584
|
+
include_untracked=args.include_untracked,
|
|
585
|
+
repo_root=repo_root,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
if args.only != "all":
|
|
589
|
+
filtered = []
|
|
590
|
+
for p in changed:
|
|
591
|
+
if (args.only == "src" and (python_file(p) and not is_test_file(p))) or (
|
|
592
|
+
args.only == "test" and is_test_file(p)
|
|
593
|
+
):
|
|
594
|
+
filtered.append(p)
|
|
595
|
+
changed = filtered
|
|
596
|
+
|
|
597
|
+
selected_tests, mapping = discover_test_files_from_changed(changed, repo_root=repo_root)
|
|
598
|
+
|
|
599
|
+
note = None
|
|
600
|
+
if not selected_tests:
|
|
601
|
+
note = "No tests matched the changed files."
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
res = None
|
|
605
|
+
if selected_tests:
|
|
606
|
+
res = run_pytest_and_collect(selected_tests, extra_args=args.extra_args, cwd=repo_root)
|
|
607
|
+
strategy = "changed-files->tests"
|
|
608
|
+
note = None
|
|
609
|
+
elif args.fallback == "last-failed":
|
|
610
|
+
extra = shlex.split(args.extra_args) if args.extra_args else []
|
|
611
|
+
res = run_pytest_and_collect([], extra_args=["--last-failed"] + extra, cwd=repo_root)
|
|
612
|
+
strategy = "fallback:last-failed"
|
|
613
|
+
note = "No tests mapped from changes; ran --last-failed."
|
|
614
|
+
elif args.fallback == "all":
|
|
615
|
+
res = run_pytest_and_collect([], extra_args=args.extra_args, cwd=repo_root)
|
|
616
|
+
strategy = "fallback:all"
|
|
617
|
+
note = "No tests mapped from changes; ran entire suite."
|
|
618
|
+
else:
|
|
619
|
+
res = {
|
|
620
|
+
"exit_code": 5,
|
|
621
|
+
"summary": {
|
|
622
|
+
"total": 0,
|
|
623
|
+
"passed": 0,
|
|
624
|
+
"failed": 0,
|
|
625
|
+
"skipped": 0,
|
|
626
|
+
"xfailed": 0,
|
|
627
|
+
"xpassed": 0,
|
|
628
|
+
"error": 0,
|
|
629
|
+
"rerun": 0,
|
|
630
|
+
"warnings": 0,
|
|
631
|
+
},
|
|
632
|
+
"tests": [],
|
|
633
|
+
"warnings": [],
|
|
634
|
+
"errors": [],
|
|
635
|
+
"selected_paths": [],
|
|
636
|
+
"duration": 0.0,
|
|
637
|
+
"pytest_version": None,
|
|
638
|
+
}
|
|
639
|
+
strategy = "none"
|
|
640
|
+
|
|
641
|
+
out = {
|
|
642
|
+
"repo_root": repo_root,
|
|
643
|
+
"base_ref": args.base_ref,
|
|
644
|
+
"changed_files": changed,
|
|
645
|
+
"selected_tests": selected_tests,
|
|
646
|
+
"mapping": mapping,
|
|
647
|
+
"strategy": strategy,
|
|
648
|
+
"note": note,
|
|
649
|
+
"results": res,
|
|
650
|
+
}
|
|
651
|
+
return out
|
|
652
|
+
|
|
653
|
+
# if args.json_out:
|
|
654
|
+
# out_path = os.path.abspath(args.json_out)
|
|
655
|
+
# with open(out_path, 'w', encoding='utf-8') as f:
|
|
656
|
+
# json.dump(out, f, indent=2, sort_keys=False)
|
|
657
|
+
# # print("Wrote JSON to %s" % out_path)
|
|
658
|
+
|
|
659
|
+
# s = res.get("summary", {})
|
|
660
|
+
# print("Strategy: %s" % strategy)
|
|
661
|
+
# if note:
|
|
662
|
+
# print("Note: %s" % note)
|
|
663
|
+
# print("Selected tests: %d" % len(out["selected_tests"]))
|
|
664
|
+
# print("Pytest exit code:", res.get("exit_code"))
|
|
665
|
+
# print("Summary: total={total} passed={passed} failed={failed} skipped={skipped} xfailed={xfailed} xpassed={xpassed} error={error}".format(**s))
|
|
666
|
+
# return int(res.get("exit_code", 1))
|
|
667
|
+
|
|
668
|
+
except SystemExit:
|
|
669
|
+
# return int(getattr(se, 'code', 1))
|
|
670
|
+
return out
|
|
671
|
+
except Exception:
|
|
672
|
+
return out
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
if __name__ == "__main__":
|
|
676
|
+
args = parse_args()
|
|
677
|
+
output = main(args)
|
|
678
|
+
# if output['results']['exit_code'] != 0:
|
|
679
|
+
if output["results"]["summary"]["total"] == 0:
|
|
680
|
+
args.extra_args = ""
|
|
681
|
+
output = main(args)
|
|
682
|
+
# if output['results']['exit_code'] != 0:
|
|
683
|
+
if output["results"]["summary"]["total"] == 0:
|
|
684
|
+
# numpy issues.
|
|
685
|
+
args.extra_args = "--import-mode=importlib --ignore=build --ignore=tools/ci -c tox.ini"
|
|
686
|
+
output = main(args)
|
|
687
|
+
|
|
688
|
+
print("FORMULACODE_TESTS_START")
|
|
689
|
+
print(json.dumps(output["results"]["summary"], sort_keys=True))
|
|
690
|
+
print("FORMULACODE_TESTS_END")
|
|
691
|
+
with open("/logs/test_results.json", "w", encoding="utf-8") as f:
|
|
692
|
+
json.dump(output, f, sort_keys=True)
|