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.
Files changed (87) hide show
  1. datasmith/__init__.py +330 -0
  2. datasmith/__init__.pyi +194 -0
  3. datasmith/agents/__init__.py +31 -0
  4. datasmith/agents/classifiers.py +272 -0
  5. datasmith/agents/codex.py +25 -0
  6. datasmith/agents/config.py +108 -0
  7. datasmith/agents/extractors.py +197 -0
  8. datasmith/agents/installed/README.md +52 -0
  9. datasmith/agents/installed/__init__.py +22 -0
  10. datasmith/agents/installed/base.py +240 -0
  11. datasmith/agents/installed/claude.py +134 -0
  12. datasmith/agents/installed/codex.py +91 -0
  13. datasmith/agents/installed/gemini.py +118 -0
  14. datasmith/agents/installed/none.py +27 -0
  15. datasmith/agents/sandbox.py +547 -0
  16. datasmith/agents/synthesizer.py +439 -0
  17. datasmith/agents/templates/AGENTS.md.j2 +150 -0
  18. datasmith/agents/templates/sandbox_verify.py +428 -0
  19. datasmith/docker/__init__.py +31 -0
  20. datasmith/docker/context.py +112 -0
  21. datasmith/docker/images.py +158 -0
  22. datasmith/docker/publish.py +56 -0
  23. datasmith/docker/templates/Dockerfile.base +26 -0
  24. datasmith/docker/templates/Dockerfile.pr +42 -0
  25. datasmith/docker/templates/Dockerfile.repo +11 -0
  26. datasmith/docker/templates/docker_build_base.sh +780 -0
  27. datasmith/docker/templates/docker_build_env.sh +309 -0
  28. datasmith/docker/templates/docker_build_final.sh +106 -0
  29. datasmith/docker/templates/docker_build_pkg.sh +99 -0
  30. datasmith/docker/templates/docker_build_run.sh +124 -0
  31. datasmith/docker/templates/entrypoint.sh +62 -0
  32. datasmith/docker/templates/parser.py +1405 -0
  33. datasmith/docker/templates/profile.sh +199 -0
  34. datasmith/docker/templates/pytest_runner.py +692 -0
  35. datasmith/docker/templates/run-tests.sh +197 -0
  36. datasmith/docker/verifiers.py +131 -0
  37. datasmith/filters.py +154 -0
  38. datasmith/github/__init__.py +22 -0
  39. datasmith/github/client.py +333 -0
  40. datasmith/github/hooks.py +50 -0
  41. datasmith/github/links.py +110 -0
  42. datasmith/github/models.py +206 -0
  43. datasmith/github/render.py +173 -0
  44. datasmith/github/search.py +66 -0
  45. datasmith/github/templates/comment.md.j2 +5 -0
  46. datasmith/github/templates/final.md.j2 +66 -0
  47. datasmith/github/templates/issues.md.j2 +21 -0
  48. datasmith/github/templates/repo.md.j2 +1 -0
  49. datasmith/preflight.py +162 -0
  50. datasmith/publish/__init__.py +13 -0
  51. datasmith/publish/huggingface.py +104 -0
  52. datasmith/publish/pipeline.py +60 -0
  53. datasmith/publish/records.py +91 -0
  54. datasmith/py.typed +1 -0
  55. datasmith/resolution/__init__.py +14 -0
  56. datasmith/resolution/blocklist.py +145 -0
  57. datasmith/resolution/cache.py +120 -0
  58. datasmith/resolution/constants.py +277 -0
  59. datasmith/resolution/dependency_resolver.py +174 -0
  60. datasmith/resolution/git_utils.py +378 -0
  61. datasmith/resolution/import_analyzer.py +66 -0
  62. datasmith/resolution/metadata_parser.py +412 -0
  63. datasmith/resolution/models.py +41 -0
  64. datasmith/resolution/orchestrator.py +522 -0
  65. datasmith/resolution/package_filters.py +312 -0
  66. datasmith/resolution/python_manager.py +110 -0
  67. datasmith/runners/__init__.py +15 -0
  68. datasmith/runners/base.py +112 -0
  69. datasmith/runners/classify_prs.py +48 -0
  70. datasmith/runners/render_problems.py +113 -0
  71. datasmith/runners/resolve_packages.py +66 -0
  72. datasmith/runners/scrape_commits.py +166 -0
  73. datasmith/runners/scrape_repos.py +44 -0
  74. datasmith/runners/synthesize_images.py +310 -0
  75. datasmith/update/__init__.py +5 -0
  76. datasmith/update/cli.py +169 -0
  77. datasmith/update/offline.py +173 -0
  78. datasmith/update/pipeline.py +497 -0
  79. datasmith/utils/__init__.py +18 -0
  80. datasmith/utils/core.py +67 -0
  81. datasmith/utils/db.py +156 -0
  82. datasmith/utils/tokens.py +65 -0
  83. fc_data-0.2.0.dist-info/METADATA +441 -0
  84. fc_data-0.2.0.dist-info/RECORD +87 -0
  85. fc_data-0.2.0.dist-info/WHEEL +4 -0
  86. fc_data-0.2.0.dist-info/entry_points.txt +2 -0
  87. 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)