bite-check 1.0.0__tar.gz → 1.0.2__tar.gz
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.
- {bite_check-1.0.0 → bite_check-1.0.2}/PKG-INFO +18 -4
- {bite_check-1.0.0 → bite_check-1.0.2}/README.md +17 -3
- {bite_check-1.0.0 → bite_check-1.0.2}/bite.py +27 -5
- {bite_check-1.0.0 → bite_check-1.0.2}/bite_check.egg-info/PKG-INFO +18 -4
- {bite_check-1.0.0 → bite_check-1.0.2}/bite_check.egg-info/SOURCES.txt +3 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/bite_check.egg-info/entry_points.txt +1 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/live.py +6 -1
- {bite_check-1.0.0 → bite_check-1.0.2}/pyproject.toml +2 -1
- {bite_check-1.0.0 → bite_check-1.0.2}/recent.py +97 -10
- bite_check-1.0.2/tests/test_first_run.py +88 -0
- bite_check-1.0.2/tests/test_hang.py +90 -0
- bite_check-1.0.2/tests/test_progress.py +81 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/LICENSE +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/ask.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/bite_check.egg-info/dependency_links.txt +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/bite_check.egg-info/requires.txt +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/bite_check.egg-info/top_level.txt +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/bite_mcp.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/repo.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/setup.cfg +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_action.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_any_encoding.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_ask.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_bite_claim.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_bot.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_claim_command.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_comeback.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_comment_size.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_copy_names.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_deleted_files.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_doctests.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_fail_comment.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_go_other_systems.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_huge_files.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_java_kotlin.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_jobs_memory.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_json.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_later_test_near_tidy.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_live.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_mcp.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_merge_shards.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_most_wanted.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_names.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_no_pings.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_node_copies.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_odd_inputs.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_odd_repos.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_open_pr.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_passing_names.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_paste_paths.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_pr_checklist.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_publish_badge.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_rust_doc_examples.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_rust_unit_tests.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_score.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_score_trend.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_self_check.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_selftest.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_settings_file.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_shallow.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_shard.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_submodules.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_tricks.py +0 -0
- {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_update.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bite-check
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Checks whether the "fixed" in your coding agent's recent commits is actually true.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/connabyss/bite
|
|
@@ -39,6 +39,20 @@ Bite checks whether an AI coding agent's "fixed" is actually true.
|
|
|
39
39
|
|
|
40
40
|
<img alt="bite --demo: one fix is noticed by a test, the other has no test at all" src="https://raw.githubusercontent.com/connabyss/bite/main/assets/bite-demo.svg" width="760">
|
|
41
41
|
|
|
42
|
+
**Try it on your own repo in one line.** It's free, needs no key, and never changes your files:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
cd your-repo
|
|
46
|
+
uvx bite-check --free
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
No [uv](https://docs.astral.sh/uv/)? `pipx run bite-check --free` does the same with [pipx](https://pipx.pypa.io).
|
|
50
|
+
If your commits never say "fix", add `--any` to check every recent change. To keep Bite installed, see
|
|
51
|
+
[Try it in a minute](#try-it-in-a-minute).
|
|
52
|
+
|
|
53
|
+
**Here for Hack Week or your first open-source PR?** These [good first issues](https://github.com/connabyss/bite/labels/good%20first%20issue)
|
|
54
|
+
are small, and each one says where to look and when it's done. Comment on one to claim it.
|
|
55
|
+
|
|
42
56
|
## Why you need this
|
|
43
57
|
|
|
44
58
|
When an AI fixes a bug for you, it often says "Fixed, and I added a test." If you don't read code, you can't tell
|
|
@@ -284,7 +298,7 @@ jobs:
|
|
|
284
298
|
# install what your tests need here, e.g. for Python:
|
|
285
299
|
- uses: actions/setup-python@v7
|
|
286
300
|
- run: python -m pip install pytest -r requirements.txt
|
|
287
|
-
- uses: connabyss/bite@v1.0.
|
|
301
|
+
- uses: connabyss/bite@v1.0.2
|
|
288
302
|
```
|
|
289
303
|
|
|
290
304
|
Options go under `with:`: `args` (extra `bite` options, e.g. `--serious`), `max-commits` (default 20),
|
|
@@ -298,7 +312,7 @@ When no test noticed a change, Bite can write that test for you. Add your Anthro
|
|
|
298
312
|
named `ANTHROPIC_API_KEY`, then:
|
|
299
313
|
|
|
300
314
|
```yaml
|
|
301
|
-
- uses: connabyss/bite@v1.0.
|
|
315
|
+
- uses: connabyss/bite@v1.0.2
|
|
302
316
|
with:
|
|
303
317
|
write-tests: true
|
|
304
318
|
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
@@ -415,7 +429,7 @@ jobs:
|
|
|
415
429
|
with:
|
|
416
430
|
fetch-depth: 0
|
|
417
431
|
# install what your tests need here, as in the pull request check
|
|
418
|
-
- uses: connabyss/bite@v1.0.
|
|
432
|
+
- uses: connabyss/bite@v1.0.2
|
|
419
433
|
with:
|
|
420
434
|
badge-branch: bite-badge
|
|
421
435
|
max-commits: 10
|
|
@@ -13,6 +13,20 @@ Bite checks whether an AI coding agent's "fixed" is actually true.
|
|
|
13
13
|
|
|
14
14
|
<img alt="bite --demo: one fix is noticed by a test, the other has no test at all" src="https://raw.githubusercontent.com/connabyss/bite/main/assets/bite-demo.svg" width="760">
|
|
15
15
|
|
|
16
|
+
**Try it on your own repo in one line.** It's free, needs no key, and never changes your files:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
cd your-repo
|
|
20
|
+
uvx bite-check --free
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
No [uv](https://docs.astral.sh/uv/)? `pipx run bite-check --free` does the same with [pipx](https://pipx.pypa.io).
|
|
24
|
+
If your commits never say "fix", add `--any` to check every recent change. To keep Bite installed, see
|
|
25
|
+
[Try it in a minute](#try-it-in-a-minute).
|
|
26
|
+
|
|
27
|
+
**Here for Hack Week or your first open-source PR?** These [good first issues](https://github.com/connabyss/bite/labels/good%20first%20issue)
|
|
28
|
+
are small, and each one says where to look and when it's done. Comment on one to claim it.
|
|
29
|
+
|
|
16
30
|
## Why you need this
|
|
17
31
|
|
|
18
32
|
When an AI fixes a bug for you, it often says "Fixed, and I added a test." If you don't read code, you can't tell
|
|
@@ -258,7 +272,7 @@ jobs:
|
|
|
258
272
|
# install what your tests need here, e.g. for Python:
|
|
259
273
|
- uses: actions/setup-python@v7
|
|
260
274
|
- run: python -m pip install pytest -r requirements.txt
|
|
261
|
-
- uses: connabyss/bite@v1.0.
|
|
275
|
+
- uses: connabyss/bite@v1.0.2
|
|
262
276
|
```
|
|
263
277
|
|
|
264
278
|
Options go under `with:`: `args` (extra `bite` options, e.g. `--serious`), `max-commits` (default 20),
|
|
@@ -272,7 +286,7 @@ When no test noticed a change, Bite can write that test for you. Add your Anthro
|
|
|
272
286
|
named `ANTHROPIC_API_KEY`, then:
|
|
273
287
|
|
|
274
288
|
```yaml
|
|
275
|
-
- uses: connabyss/bite@v1.0.
|
|
289
|
+
- uses: connabyss/bite@v1.0.2
|
|
276
290
|
with:
|
|
277
291
|
write-tests: true
|
|
278
292
|
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
@@ -389,7 +403,7 @@ jobs:
|
|
|
389
403
|
with:
|
|
390
404
|
fetch-depth: 0
|
|
391
405
|
# install what your tests need here, as in the pull request check
|
|
392
|
-
- uses: connabyss/bite@v1.0.
|
|
406
|
+
- uses: connabyss/bite@v1.0.2
|
|
393
407
|
with:
|
|
394
408
|
badge-branch: bite-badge
|
|
395
409
|
max-commits: 10
|
|
@@ -659,11 +659,30 @@ def locked_run(cfg: Config, cmd: str, cwd: Path, lane: str = ""):
|
|
|
659
659
|
if why:
|
|
660
660
|
return None, f"not run: {why}"
|
|
661
661
|
try:
|
|
662
|
-
return run_cmd(contain(cfg, cmd, cwd), cwd, cfg
|
|
662
|
+
return run_cmd(contain(cfg, cmd, cwd), cwd, run_limit(cfg))
|
|
663
663
|
finally:
|
|
664
664
|
lock.release()
|
|
665
665
|
|
|
666
666
|
|
|
667
|
+
# A run with a change taken out that takes this many times as long as the run with it in (and at least
|
|
668
|
+
# HANG_FLOOR seconds) is a hang: an unbounded loop the change had bounded, say. Waiting out test_timeout for it
|
|
669
|
+
# (10 minutes by default, and the flaky rerun and the piece check wait again) stalled whole runs.
|
|
670
|
+
HANG_TIMES, HANG_FLOOR = 5, 60
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def run_limit(cfg) -> int:
|
|
674
|
+
"""How long one test run may take: test_timeout, or less once cap_hang has seen how long the tests take."""
|
|
675
|
+
cap = getattr(cfg, "hang_cap", 0)
|
|
676
|
+
return min(cfg.test_timeout, cap) if cap else cfg.test_timeout
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def cap_hang(cfg, now: dict) -> None:
|
|
680
|
+
"""The tests took now["secs"] with the change in. Later runs on this copy (the change taken out) stop at
|
|
681
|
+
HANG_TIMES that, so a change whose removal makes the tests hang shows as noticed in a minute or two."""
|
|
682
|
+
if now.get("secs") is not None:
|
|
683
|
+
cfg.hang_cap = max(HANG_FLOOR, int(HANG_TIMES * now["secs"]) + 1)
|
|
684
|
+
|
|
685
|
+
|
|
667
686
|
EXTRA_ENV = {} # e.g. BITE_PERSONA and BITE_OUT for a crowd run
|
|
668
687
|
|
|
669
688
|
|
|
@@ -712,11 +731,14 @@ def exit_why(cmd: str, rc: int) -> str:
|
|
|
712
731
|
|
|
713
732
|
def run_tests(cfg: Config, folder: Path) -> dict:
|
|
714
733
|
cmd = tests_cmd(cfg).replace("{project}", str(folder)).replace("{seed}", SEED[0])
|
|
734
|
+
start = time.monotonic()
|
|
715
735
|
rc, out = locked_run(cfg, cmd, folder)
|
|
736
|
+
secs = round(time.monotonic() - start, 1)
|
|
716
737
|
if rc is None:
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
738
|
+
hung = not out.startswith("not run:")
|
|
739
|
+
why = f"timed out after {run_limit(cfg)}s (test_command must exit on its own)" if hung else out
|
|
740
|
+
return {"ok": False, "why": why, "fails": [], "count": None, "out": out, "hung": hung,
|
|
741
|
+
"secs": secs if hung else None}
|
|
720
742
|
fails = [ln.strip() for ln in out.splitlines() if re.search(cfg.fail_pattern, ln) and not PASSED_LINE.match(ln)]
|
|
721
743
|
count = None
|
|
722
744
|
if cfg.count_pattern:
|
|
@@ -726,7 +748,7 @@ def run_tests(cfg: Config, folder: Path) -> dict:
|
|
|
726
748
|
count = auto_count(out)
|
|
727
749
|
ok = rc in cfg.ok_codes and not fails and (count is None or count > 0)
|
|
728
750
|
why = (exit_why(cmd, rc) if rc else "") or (fails[0][:160] if fails else "") or ("no tests ran" if count == 0 else "")
|
|
729
|
-
return {"ok": ok, "why": why, "fails": fails, "count": count, "out": out}
|
|
751
|
+
return {"ok": ok, "why": why, "fails": fails, "count": count, "out": out, "secs": secs}
|
|
730
752
|
|
|
731
753
|
|
|
732
754
|
def safe_write(root: Path, target: Path, data: bytes):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bite-check
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Checks whether the "fixed" in your coding agent's recent commits is actually true.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/connabyss/bite
|
|
@@ -39,6 +39,20 @@ Bite checks whether an AI coding agent's "fixed" is actually true.
|
|
|
39
39
|
|
|
40
40
|
<img alt="bite --demo: one fix is noticed by a test, the other has no test at all" src="https://raw.githubusercontent.com/connabyss/bite/main/assets/bite-demo.svg" width="760">
|
|
41
41
|
|
|
42
|
+
**Try it on your own repo in one line.** It's free, needs no key, and never changes your files:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
cd your-repo
|
|
46
|
+
uvx bite-check --free
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
No [uv](https://docs.astral.sh/uv/)? `pipx run bite-check --free` does the same with [pipx](https://pipx.pypa.io).
|
|
50
|
+
If your commits never say "fix", add `--any` to check every recent change. To keep Bite installed, see
|
|
51
|
+
[Try it in a minute](#try-it-in-a-minute).
|
|
52
|
+
|
|
53
|
+
**Here for Hack Week or your first open-source PR?** These [good first issues](https://github.com/connabyss/bite/labels/good%20first%20issue)
|
|
54
|
+
are small, and each one says where to look and when it's done. Comment on one to claim it.
|
|
55
|
+
|
|
42
56
|
## Why you need this
|
|
43
57
|
|
|
44
58
|
When an AI fixes a bug for you, it often says "Fixed, and I added a test." If you don't read code, you can't tell
|
|
@@ -284,7 +298,7 @@ jobs:
|
|
|
284
298
|
# install what your tests need here, e.g. for Python:
|
|
285
299
|
- uses: actions/setup-python@v7
|
|
286
300
|
- run: python -m pip install pytest -r requirements.txt
|
|
287
|
-
- uses: connabyss/bite@v1.0.
|
|
301
|
+
- uses: connabyss/bite@v1.0.2
|
|
288
302
|
```
|
|
289
303
|
|
|
290
304
|
Options go under `with:`: `args` (extra `bite` options, e.g. `--serious`), `max-commits` (default 20),
|
|
@@ -298,7 +312,7 @@ When no test noticed a change, Bite can write that test for you. Add your Anthro
|
|
|
298
312
|
named `ANTHROPIC_API_KEY`, then:
|
|
299
313
|
|
|
300
314
|
```yaml
|
|
301
|
-
- uses: connabyss/bite@v1.0.
|
|
315
|
+
- uses: connabyss/bite@v1.0.2
|
|
302
316
|
with:
|
|
303
317
|
write-tests: true
|
|
304
318
|
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
@@ -415,7 +429,7 @@ jobs:
|
|
|
415
429
|
with:
|
|
416
430
|
fetch-depth: 0
|
|
417
431
|
# install what your tests need here, as in the pull request check
|
|
418
|
-
- uses: connabyss/bite@v1.0.
|
|
432
|
+
- uses: connabyss/bite@v1.0.2
|
|
419
433
|
with:
|
|
420
434
|
badge-branch: bite-badge
|
|
421
435
|
max-commits: 10
|
|
@@ -25,7 +25,9 @@ tests/test_copy_names.py
|
|
|
25
25
|
tests/test_deleted_files.py
|
|
26
26
|
tests/test_doctests.py
|
|
27
27
|
tests/test_fail_comment.py
|
|
28
|
+
tests/test_first_run.py
|
|
28
29
|
tests/test_go_other_systems.py
|
|
30
|
+
tests/test_hang.py
|
|
29
31
|
tests/test_huge_files.py
|
|
30
32
|
tests/test_java_kotlin.py
|
|
31
33
|
tests/test_jobs_memory.py
|
|
@@ -44,6 +46,7 @@ tests/test_open_pr.py
|
|
|
44
46
|
tests/test_passing_names.py
|
|
45
47
|
tests/test_paste_paths.py
|
|
46
48
|
tests/test_pr_checklist.py
|
|
49
|
+
tests/test_progress.py
|
|
47
50
|
tests/test_publish_badge.py
|
|
48
51
|
tests/test_rust_doc_examples.py
|
|
49
52
|
tests/test_rust_unit_tests.py
|
|
@@ -112,12 +112,14 @@ def check(root: Path, base: str = "HEAD") -> dict:
|
|
|
112
112
|
return {**r, "status": "cant", "why": f"the tests fail with the change in ({now['why']})"}
|
|
113
113
|
if recent.ran_nothing(bite.tests_cmd(fcfg), now["out"]):
|
|
114
114
|
return {**r, "status": "cant", "why": "no tests ran"}
|
|
115
|
+
bite.cap_hang(fcfg, now)
|
|
115
116
|
recent.take_out(root, {"code": code, "before": base}, folder)
|
|
116
117
|
out = bite.run_tests(fcfg, folder)
|
|
117
118
|
if out["ok"]:
|
|
118
119
|
test_code = [n for n in tests if Path(n).suffix.lower() in repo.CODE_SUFFIXES]
|
|
119
120
|
return {**r, "status": "tests_blind" if test_code else "unguarded", "test_code": test_code}
|
|
120
|
-
|
|
121
|
+
caught = recent.caught_by(out["out"])
|
|
122
|
+
return {**r, "status": "guarded", "caught": caught, "hang": "" if caught else recent.hang_note(out, now)}
|
|
121
123
|
finally:
|
|
122
124
|
shutil.rmtree(folder.parent, ignore_errors=True)
|
|
123
125
|
|
|
@@ -134,6 +136,9 @@ def say(r: dict) -> str:
|
|
|
134
136
|
return "Bite: no code your tests run has changed, so there's nothing to check."
|
|
135
137
|
if s == "cant":
|
|
136
138
|
return f"Bite couldn't check this change: {r['why']}."
|
|
139
|
+
if s == "guarded" and r.get("hang"):
|
|
140
|
+
return (f"Bite ✅ the tests noticed: with the change to {files(r['code'])} taken out, they hang "
|
|
141
|
+
f"({r['hang']}).")
|
|
137
142
|
if s == "guarded":
|
|
138
143
|
who = f" ({r['caught']})" if r.get("caught") else ""
|
|
139
144
|
return f"Bite ✅ a test noticed: with the change to {files(r['code'])} taken out, the tests fail{who}."
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bite-check"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.2"
|
|
8
8
|
description = "Checks whether the \"fixed\" in your coding agent's recent commits is actually true."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -27,6 +27,7 @@ test = ["pytest", "pytest-xdist"]
|
|
|
27
27
|
|
|
28
28
|
[project.scripts]
|
|
29
29
|
bite = "recent:main"
|
|
30
|
+
bite-check = "recent:main" # so `uvx bite-check` and `pipx run bite-check` work with no --from
|
|
30
31
|
|
|
31
32
|
[project.urls]
|
|
32
33
|
Homepage = "https://github.com/connabyss/bite"
|
|
@@ -945,25 +945,43 @@ def tests_pass_at(root: Path, sha: str, cfg) -> tuple[bool, str]:
|
|
|
945
945
|
JOBS = [1] # --jobs N: how many commits are checked at the same time
|
|
946
946
|
_GATE = threading.Condition()
|
|
947
947
|
_BUSY = [0, False] # test runs going on side by side, and whether one has the machine to itself
|
|
948
|
+
_SHARED = {} # each test run going on now: whether another ran beside it at any point
|
|
948
949
|
|
|
949
950
|
|
|
950
|
-
def run_tests(fcfg, folder) -> dict:
|
|
951
|
+
def run_tests(fcfg, folder, alone: bool = False, confirm: bool = True) -> dict:
|
|
951
952
|
"""bite.run_tests, and with --jobs, side by side with other commits' runs. Tests run side by side can trip over
|
|
952
953
|
each other (the same port, the same database), and a failure caused by that would read as "a test noticed" and
|
|
953
|
-
hide a real gap. So with --jobs a failed run
|
|
954
|
+
hide a real gap. So with --jobs a failed run that shared the machine with another run counts only if it fails
|
|
955
|
+
again with the machine to itself. A run that had the machine to itself anyway (one commit to check, or the
|
|
956
|
+
others already done) isn't run twice: that only doubled the time of every failing run.
|
|
957
|
+
alone: run it with the machine to itself straight away. confirm=False: return a failed shared run as it is
|
|
958
|
+
(marked "shared"), for a caller whose next run (alone) confirms it anyway."""
|
|
954
959
|
if JOBS[0] <= 1:
|
|
955
960
|
return bite.run_tests(fcfg, folder)
|
|
961
|
+
if alone:
|
|
962
|
+
return _run_alone(fcfg, folder)
|
|
963
|
+
me = object()
|
|
956
964
|
with _GATE:
|
|
957
965
|
_GATE.wait_for(lambda: not _BUSY[1])
|
|
958
966
|
_BUSY[0] += 1
|
|
967
|
+
_SHARED[me] = bool(_SHARED)
|
|
968
|
+
for other in _SHARED: # every run going on now shares the machine with this one
|
|
969
|
+
_SHARED[other] = _SHARED[other] or _SHARED[me]
|
|
959
970
|
try:
|
|
960
971
|
r = bite.run_tests(fcfg, folder)
|
|
961
972
|
finally:
|
|
962
973
|
with _GATE:
|
|
963
974
|
_BUSY[0] -= 1
|
|
975
|
+
shared = _SHARED.pop(me)
|
|
964
976
|
_GATE.notify_all()
|
|
965
|
-
if r["ok"]:
|
|
977
|
+
if r["ok"] or not shared:
|
|
966
978
|
return r
|
|
979
|
+
if not confirm:
|
|
980
|
+
return {**r, "shared": True}
|
|
981
|
+
return _run_alone(fcfg, folder)
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _run_alone(fcfg, folder) -> dict:
|
|
967
985
|
with _GATE:
|
|
968
986
|
_GATE.wait_for(lambda: not _BUSY[1] and not _BUSY[0])
|
|
969
987
|
_BUSY[1] = True
|
|
@@ -1019,6 +1037,11 @@ def remember(root: Path, memo: dict) -> None:
|
|
|
1019
1037
|
(proof_dir(root) / "checked.json").write_text(json.dumps(memo, indent=1), encoding="utf-8")
|
|
1020
1038
|
|
|
1021
1039
|
|
|
1040
|
+
def doing(c: dict, text: str) -> None:
|
|
1041
|
+
"""One line before each test run, so a slow check shows what it is doing instead of going quiet."""
|
|
1042
|
+
print(f" {c['sha'][:8]} {text}", flush=True)
|
|
1043
|
+
|
|
1044
|
+
|
|
1022
1045
|
def undo_check(root: Path, c: dict, cfg) -> str:
|
|
1023
1046
|
"""check_undo, but a file Bite can't read or write in one commit's copy (a link, a locked file, a full disk)
|
|
1024
1047
|
only stops the check of that commit, not the whole run: it shows as couldn't check, with the reason."""
|
|
@@ -1040,14 +1063,21 @@ def check_undo(root: Path, c: dict, cfg) -> str:
|
|
|
1040
1063
|
if getattr(cfg, "free_cmd", ""): # the faster subset, for this step only
|
|
1041
1064
|
fcfg.test_cmd, fcfg.box_test_cmd = cfg.free_cmd, ""
|
|
1042
1065
|
try:
|
|
1066
|
+
doing(c, "running the tests with the change in...")
|
|
1043
1067
|
now = run_tests(fcfg, folder)
|
|
1044
1068
|
if not now["ok"]:
|
|
1045
1069
|
c["why"] = fail_why(now)
|
|
1046
1070
|
return "tests fail at this commit"
|
|
1047
1071
|
if ran_nothing(bite.tests_cmd(fcfg), now["out"]):
|
|
1048
1072
|
return "no tests ran at this commit"
|
|
1073
|
+
bite.cap_hang(fcfg, now)
|
|
1049
1074
|
take_out(root, c, folder)
|
|
1050
|
-
|
|
1075
|
+
doing(c, f"running them with the change taken out (they took {took(now)} with it in)...")
|
|
1076
|
+
# A failure beside another commit's run (--jobs) is confirmed by the next run, alone: the rerun below, or
|
|
1077
|
+
# here when there is none. Confirming it straight away too ran every noticed change's tests twice more.
|
|
1078
|
+
undone = run_tests(fcfg, folder, confirm=False)
|
|
1079
|
+
if undone.get("shared") and (QUICK[0] or bite.load_failure(undone["out"])):
|
|
1080
|
+
undone = run_tests(fcfg, folder, alone=True)
|
|
1051
1081
|
|
|
1052
1082
|
def flag(): # would be flagged: first rule out the usual harmless reasons
|
|
1053
1083
|
if c.get("skip"):
|
|
@@ -1065,11 +1095,15 @@ def check_undo(root: Path, c: dict, cfg) -> str:
|
|
|
1065
1095
|
if not crashed and not QUICK[0]:
|
|
1066
1096
|
# Once more: a test that fails now and then (flaky) can fail by luck. Only a test that fails every
|
|
1067
1097
|
# time the change is out counts as noticing it.
|
|
1068
|
-
|
|
1098
|
+
doing(c, "a test failed: running them once more, to rule out a test that fails by luck"
|
|
1099
|
+
+ (" (alone, so the other check running beside it can't be the cause)..." if undone.get("shared") else "..."))
|
|
1100
|
+
again = run_tests(fcfg, folder, alone=bool(undone.get("shared")))
|
|
1069
1101
|
if again["ok"]:
|
|
1070
|
-
|
|
1102
|
+
if not undone.get("shared"): # beside another run, it may have been the other run's doing
|
|
1103
|
+
c["flaky"] = caught_by(undone["out"]) or "a test"
|
|
1071
1104
|
return flag()
|
|
1072
1105
|
c["caught"] = caught_by(undone["out"])
|
|
1106
|
+
hung = not c["caught"] and hang_note(undone, now)
|
|
1073
1107
|
missed = unnoticed_part(root, c, folder, fcfg)
|
|
1074
1108
|
if missed and c.get("skip"):
|
|
1075
1109
|
return f"not flagged: {c['skip']}"
|
|
@@ -1091,6 +1125,9 @@ def check_undo(root: Path, c: dict, cfg) -> str:
|
|
|
1091
1125
|
c["caught"], c["gone"] = caught, gone
|
|
1092
1126
|
return "guard gone since"
|
|
1093
1127
|
c["caught"] = caught
|
|
1128
|
+
if hung:
|
|
1129
|
+
c["hang"] = hung
|
|
1130
|
+
return "guarded (tests hang without it)"
|
|
1094
1131
|
return "guarded" if not crashed else "guarded (tests use the new code)"
|
|
1095
1132
|
finally:
|
|
1096
1133
|
shutil.rmtree(folder.parent, ignore_errors=True)
|
|
@@ -1229,7 +1266,7 @@ def unnoticed_part(root: Path, c: dict, folder: Path, fcfg) -> list:
|
|
|
1229
1266
|
PART_RUNS[0] -= len(ps)
|
|
1230
1267
|
news = {rel: repo.git(root, "cat-file", "blob", f"{c['sha']}:{rel}", raw=True) for rel in c["code"]}
|
|
1231
1268
|
missed = []
|
|
1232
|
-
for p in ps:
|
|
1269
|
+
for n, p in enumerate(ps, 1):
|
|
1233
1270
|
rel, undone = p[0], p[2]
|
|
1234
1271
|
for r, b in news.items(): # the whole change back in
|
|
1235
1272
|
if b is not None:
|
|
@@ -1238,6 +1275,7 @@ def unnoticed_part(root: Path, c: dict, folder: Path, fcfg) -> list:
|
|
|
1238
1275
|
take_out(root, {**c, "code": [rel]}, folder) # a file the commit created
|
|
1239
1276
|
else:
|
|
1240
1277
|
bite.safe_write(folder, folder / rel, undone)
|
|
1278
|
+
doing(c, f"piece {n} of {len(ps)} ({p[1] + ' in ' if p[1] else ''}{p[0]}) taken out alone: does a test notice it?")
|
|
1241
1279
|
run = run_tests(fcfg, folder)
|
|
1242
1280
|
if run["ok"] and not ran_nothing(bite.tests_cmd(fcfg), run["out"]):
|
|
1243
1281
|
missed.append(p)
|
|
@@ -1259,6 +1297,18 @@ def caught_by(out: str) -> str:
|
|
|
1259
1297
|
return f"`{names[0]}`" + (f" and {len(names) - 1} more" if len(names) > 1 else "")
|
|
1260
1298
|
|
|
1261
1299
|
|
|
1300
|
+
def took(run: dict) -> str:
|
|
1301
|
+
secs = round(run.get("secs") or 0)
|
|
1302
|
+
return f"{secs // 60}m {secs % 60}s" if secs >= 60 else f"{secs}s"
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def hang_note(undone: dict, now: dict) -> str:
|
|
1306
|
+
"""When the tests hung with the change taken out (see bite.cap_hang), that is what noticed it, in words."""
|
|
1307
|
+
if not undone.get("hung"):
|
|
1308
|
+
return ""
|
|
1309
|
+
return f"stopped after {round(undone['secs'])}s; they take {round(now['secs'])}s with it"
|
|
1310
|
+
|
|
1311
|
+
|
|
1262
1312
|
def closed_since(root: Path, c: dict, cfg) -> bool:
|
|
1263
1313
|
"""A later commit may have closed the gap: on today's code, take this commit's change back out (if it still
|
|
1264
1314
|
applies) and see whether today's tests now fail where they passed before."""
|
|
@@ -1316,14 +1366,18 @@ def today_notices(root: Path, c: dict, cfg):
|
|
|
1316
1366
|
diff, flags = change_diff(root, c, 0), ["--unidiff-zero"]
|
|
1317
1367
|
if not diff or not git_apply_ok(folder, diff, "-R", "--check", *flags):
|
|
1318
1368
|
return None # today's code moved on: can't tell
|
|
1369
|
+
doing(c, "on today's code: running the tests with the change in...")
|
|
1319
1370
|
now = run_tests(fcfg, folder)
|
|
1320
1371
|
if not now["ok"] or ran_nothing(bite.tests_cmd(fcfg), now["out"]):
|
|
1321
1372
|
return None
|
|
1373
|
+
bite.cap_hang(fcfg, now)
|
|
1322
1374
|
git_apply_ok(folder, diff, "-R", *flags)
|
|
1375
|
+
doing(c, "on today's code: running them with the change taken out...")
|
|
1323
1376
|
undone = run_tests(fcfg, folder)
|
|
1324
1377
|
if undone["ok"]:
|
|
1325
1378
|
return False
|
|
1326
|
-
|
|
1379
|
+
hung = hang_note(undone, now)
|
|
1380
|
+
c["caught"] = caught_by(undone["out"]) or (f"the tests hang without it: {hung}" if hung else "")
|
|
1327
1381
|
return None if bite.load_failure(undone["out"]) else True
|
|
1328
1382
|
finally:
|
|
1329
1383
|
shutil.rmtree(folder.parent, ignore_errors=True)
|
|
@@ -1750,6 +1804,9 @@ def say(c: dict, newest: str = "") -> tuple[str, str, str]:
|
|
|
1750
1804
|
"today's code. **Next:** if that wasn't on purpose, put the fix back with a test that fails without it.")
|
|
1751
1805
|
if u.startswith("closed since"):
|
|
1752
1806
|
return "held", "✅ a later test notices", f"✅ A test added in a later commit notices it now{f' ({caught})' if caught else ''}."
|
|
1807
|
+
if u == "guarded (tests hang without it)":
|
|
1808
|
+
return ("held", "✅ tests hang without it", "✅ Without this change your tests hang"
|
|
1809
|
+
f"{f' ({h})' if (h := c.get('hang')) else ''}, so they'd notice if it went.")
|
|
1753
1810
|
if u == "guarded (tests use the new code)":
|
|
1754
1811
|
return "held", "✅ tests crash without it", "✅ Without this change your tests crash, so they'd notice if it went."
|
|
1755
1812
|
if u.startswith("guarded"):
|
|
@@ -2519,7 +2576,31 @@ def _newer(latest, current):
|
|
|
2519
2576
|
return False
|
|
2520
2577
|
|
|
2521
2578
|
|
|
2579
|
+
def leave_own_python() -> None:
|
|
2580
|
+
"""Run the project's tests with the project's Python, not Bite's own. A tool runner like uvx
|
|
2581
|
+
(`uvx --from bite-check bite --free`) puts Bite's private environment first on PATH, so "python3 -m pytest"
|
|
2582
|
+
found a Python with no pytest and none of the project's packages. Drop that folder from PATH unless it's an
|
|
2583
|
+
environment the user turned on (VIRTUAL_ENV) or one inside the project (uv run, poetry, .venv)."""
|
|
2584
|
+
if sys.prefix == sys.base_prefix:
|
|
2585
|
+
return # Bite runs in a plain Python: nothing of its own to step around
|
|
2586
|
+
own = Path(sys.prefix, "Scripts" if bite.WINDOWS else "bin")
|
|
2587
|
+
parts = os.environ.get("PATH", "").split(os.pathsep)
|
|
2588
|
+
same = lambda a, b: os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b))
|
|
2589
|
+
if not parts or not parts[0] or not same(parts[0], own):
|
|
2590
|
+
return # pipx, pip install and the plugin leave PATH alone: their Python is found the usual way
|
|
2591
|
+
if os.environ.get("VIRTUAL_ENV") and same(os.environ["VIRTUAL_ENV"], sys.prefix):
|
|
2592
|
+
return
|
|
2593
|
+
try:
|
|
2594
|
+
Path.cwd().resolve().relative_to(Path(sys.prefix).resolve().parent)
|
|
2595
|
+
return # the project's own .venv, with Bite installed in it
|
|
2596
|
+
except ValueError:
|
|
2597
|
+
pass
|
|
2598
|
+
os.environ["PATH"] = os.pathsep.join(p for p in parts if not (p and same(p, own)))
|
|
2599
|
+
|
|
2600
|
+
|
|
2522
2601
|
def main(argv=None):
|
|
2602
|
+
if argv is None: # run from the command line (a test calling main() keeps its own PATH)
|
|
2603
|
+
leave_own_python()
|
|
2523
2604
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
2524
2605
|
if "--json" in argv and argv[:1] not in (["live"], ["ask"], ["mcp"]) and not {"-h", "--help", "--version"} & set(argv):
|
|
2525
2606
|
# Only the results go to stdout, so a program can read them; what Bite says along the way goes to stderr.
|
|
@@ -2624,6 +2705,7 @@ def main(argv=None):
|
|
|
2624
2705
|
return 1
|
|
2625
2706
|
t0 = time.time()
|
|
2626
2707
|
cs = commits(root, opt("--last", 40), since) # --all is still accepted: every commit is the default now
|
|
2708
|
+
seen = len(cs)
|
|
2627
2709
|
top = cs[0]["sha"] if cs else "" # the newest commit of all, even one that changed no code
|
|
2628
2710
|
for c in cs:
|
|
2629
2711
|
files_of(root, c, cfg)
|
|
@@ -2639,7 +2721,11 @@ def main(argv=None):
|
|
|
2639
2721
|
+ ", ".join(c["sha"][:8] for c in other), flush=True)
|
|
2640
2722
|
if not cs:
|
|
2641
2723
|
thin = repo.shallow(root) # --last can't help: the history isn't there
|
|
2642
|
-
|
|
2724
|
+
whole = not since and seen < int(opt("--last", 40)) # git had fewer commits than asked for
|
|
2725
|
+
print(f"Nothing to check. {repo.SHALLOW}" if thin else
|
|
2726
|
+
f"Nothing to check: that was every commit in this repository ({seen}), and none changed code your "
|
|
2727
|
+
f"tests run ({lang}). Make a change with a test, commit it, and run Bite again." if whole else
|
|
2728
|
+
"Nothing to check. Try a larger --last.")
|
|
2643
2729
|
write_comment(root, [], none=f"The code changed here isn't {lang}, and your tests only run {lang}, so they "
|
|
2644
2730
|
"can't notice it." if other else repo.SHALLOW if thin else "")
|
|
2645
2731
|
return 0
|
|
@@ -2669,7 +2755,8 @@ def main(argv=None):
|
|
|
2669
2755
|
cs = [c for c in cs if c["test_code"]]
|
|
2670
2756
|
print(f"No fix claims found in the last {opt('--last', 40)} commits, so there's nothing to hold them to"
|
|
2671
2757
|
+ (f". Checking the {len(cs)} that added tests: do those tests check the change?" if cs else
|
|
2672
|
-
"
|
|
2758
|
+
", and none of them added tests. To check every change anyway, run:\n "
|
|
2759
|
+
+ " ".join(["bite", *argv, "--any"])), flush=True)
|
|
2673
2760
|
if not cs:
|
|
2674
2761
|
write_comment(root, [], none="No commit in this pull request says it fixes something, so there's no claim to check.")
|
|
2675
2762
|
return 0
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""A newcomer's first run on their own small repo, walked through by hand (2026-11-02) before Hack Week. Three dead
|
|
2
|
+
ends, each fixed here, and each test failed before its fix. No key, no network.
|
|
3
|
+
|
|
4
|
+
1. `uvx --from bite-check bite --free` put Bite's own private Python first on PATH, so the tests ran with a Python
|
|
5
|
+
that has no pytest and none of the project's packages: "pytest isn't installed for this Python".
|
|
6
|
+
2. A repo whose commits never say "fix" and never added tests ended with "(--any checks them anyway.)", and no
|
|
7
|
+
command to copy.
|
|
8
|
+
3. A tiny repo whose few commits changed no code said "Try a larger --last", which can't help: Bite already saw
|
|
9
|
+
every commit there is."""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import venv
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
KIT = Path(__file__).resolve().parent.parent
|
|
19
|
+
PY = sys.executable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def git(root, *a):
|
|
23
|
+
return subprocess.run(["git", *a], cwd=root, check=True, capture_output=True, text=True).stdout
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def commit(root, msg):
|
|
27
|
+
git(root, "add", "-A")
|
|
28
|
+
git(root, "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-q", "-m", msg)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def bite(root, *args, env=None, py=PY):
|
|
32
|
+
return subprocess.run([py, str(KIT / "recent.py"), *args], cwd=root, capture_output=True, text=True,
|
|
33
|
+
timeout=300, env=env).stdout
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def shop(tmp_path, fix_msg="Fix total rounding"):
|
|
37
|
+
root = tmp_path / "shop"
|
|
38
|
+
(root / "tests").mkdir(parents=True)
|
|
39
|
+
git(root, "init", "-q")
|
|
40
|
+
(root / "shop.py").write_text("def total(xs):\n return sum(xs)\n")
|
|
41
|
+
(root / "tests" / "test_shop.py").write_text("from shop import total\n\n\ndef test_total():\n"
|
|
42
|
+
" assert total([1, 2]) == 3\n")
|
|
43
|
+
commit(root, "start")
|
|
44
|
+
(root / "shop.py").write_text("def total(xs):\n return round(sum(xs), 2)\n")
|
|
45
|
+
commit(root, fix_msg)
|
|
46
|
+
return root
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_run_through_uvx_uses_the_projects_python_not_bites_own(tmp_path):
|
|
50
|
+
root = shop(tmp_path)
|
|
51
|
+
own = tmp_path / "uv-cache" / "bite-env" # stands in for uvx's private environment, which has no pytest
|
|
52
|
+
venv.create(own, with_pip=False)
|
|
53
|
+
bin_dir = own / ("Scripts" if os.name == "nt" else "bin")
|
|
54
|
+
own_py = next(p for p in (bin_dir / "python.exe", bin_dir / "python3", bin_dir / "python") if p.exists())
|
|
55
|
+
env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "PYTHONHOME", "PYTHONPATH")}
|
|
56
|
+
# uvx's PATH: its own bin first, then the user's (here: the Python that has pytest, as the project's would)
|
|
57
|
+
env["PATH"] = os.pathsep.join([str(bin_dir), str(Path(PY).parent), env.get("PATH", "")])
|
|
58
|
+
assert shutil.which("python", path=env["PATH"]) or shutil.which("python3", path=env["PATH"])
|
|
59
|
+
out = bite(root, "--free", env=env, py=str(own_py))
|
|
60
|
+
assert "isn't installed for this Python" not in out, out
|
|
61
|
+
assert "noticed" in out.lower(), out # the check actually ran the tests
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_no_claims_and_no_new_tests_gives_the_command_to_check_anyway(tmp_path):
|
|
65
|
+
root = shop(tmp_path, fix_msg="round the total")
|
|
66
|
+
out = bite(root, "--free")
|
|
67
|
+
assert "bite --free --any" in out, out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_a_tiny_repo_with_no_code_changes_doesnt_say_try_a_larger_last(tmp_path):
|
|
71
|
+
root = tmp_path / "notes"
|
|
72
|
+
(root / "tests").mkdir(parents=True)
|
|
73
|
+
git(root, "init", "-q")
|
|
74
|
+
(root / "notes.py").write_text("X = 1\n")
|
|
75
|
+
(root / "tests" / "test_notes.py").write_text("def test_x():\n assert True\n")
|
|
76
|
+
(root / "README.md").write_text("# notes\n")
|
|
77
|
+
commit(root, "start")
|
|
78
|
+
(root / "README.md").write_text("# my notes\n")
|
|
79
|
+
commit(root, "Fix the title")
|
|
80
|
+
out = bite(root, "--free")
|
|
81
|
+
assert "Try a larger --last" not in out and "every commit in this repository" in out, out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_the_package_name_is_also_a_command_so_uvx_bite_check_works():
|
|
85
|
+
# `uvx bite-check --free` and `pipx run bite-check --free` look for a command named after the package.
|
|
86
|
+
import re
|
|
87
|
+
scripts = (KIT / "pyproject.toml").read_text(encoding="utf-8").split("[project.scripts]", 1)[1].split("\n[", 1)[0]
|
|
88
|
+
assert re.search(r'^bite-check = "recent:main"', scripts, re.M), scripts
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""A fix that bounds a loop: with it taken out, the tests hang. That is the tests noticing, and Bite should say so
|
|
2
|
+
in about a minute, not wait out test_timeout (10 minutes) for every run of the check. Found on Scrapling, whose
|
|
3
|
+
"bound Cloudflare solver loops" fix stalled a free check for 25 minutes."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
14
|
+
import bite # noqa: E402
|
|
15
|
+
import live # noqa: E402
|
|
16
|
+
import recent # noqa: E402
|
|
17
|
+
|
|
18
|
+
HERE = str(Path(recent.__file__).resolve().parent)
|
|
19
|
+
GIT = ["git", "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"]
|
|
20
|
+
|
|
21
|
+
LOOP = '''def first_ready(flags):
|
|
22
|
+
i = 0
|
|
23
|
+
while not flags[i % len(flags)]:
|
|
24
|
+
i += 1
|
|
25
|
+
return i % len(flags)
|
|
26
|
+
'''
|
|
27
|
+
BOUNDED = '''def first_ready(flags):
|
|
28
|
+
for i, ok in enumerate(flags):
|
|
29
|
+
if ok:
|
|
30
|
+
return i
|
|
31
|
+
return None
|
|
32
|
+
'''
|
|
33
|
+
TEST = ("from waiter import first_ready\n\n\ndef test_nothing_ready():\n assert first_ready([False, False]) is None\n\n\n"
|
|
34
|
+
"def test_second_ready():\n assert first_ready([False, True]) == 1\n")
|
|
35
|
+
# Without the fix the hung run waits out this limit, so the time check below fails in two minutes, not ten.
|
|
36
|
+
SETTINGS = "[bite]\ntest_command = python -m pytest -q -p no:cacheprovider\ntest_timeout_seconds = 120\n"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def project() -> Path:
|
|
40
|
+
root = Path(tempfile.mkdtemp(prefix="bite_hang_"))
|
|
41
|
+
subprocess.run(["git", "init", "-q"], cwd=root, check=True)
|
|
42
|
+
steps = [("Start", {"waiter.py": LOOP, ".bite.ini": SETTINGS}),
|
|
43
|
+
("Fix first_ready looping forever when nothing is ready", {"waiter.py": BOUNDED, "test_waiter.py": TEST})]
|
|
44
|
+
for subject, files in steps:
|
|
45
|
+
for name, text in files.items():
|
|
46
|
+
(root / name).write_text(text, encoding="utf-8", newline="\n")
|
|
47
|
+
subprocess.run([*GIT, "add", "-A"], cwd=root, check=True)
|
|
48
|
+
subprocess.run([*GIT, "commit", "-q", "-m", subject], cwd=root, check=True)
|
|
49
|
+
return root
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# A floor of 3 seconds instead of 60, so the test is quick; the rule (5 times the passing run) is the real one.
|
|
53
|
+
RUN = ("import sys; sys.path.insert(0, sys.argv[1]); import bite; bite.HANG_FLOOR = 3; import recent; "
|
|
54
|
+
"sys.exit(recent.main(['--free', '--any', '--quick', '--json']))")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_a_hang_with_the_fix_taken_out_counts_as_noticed_and_stops_early():
|
|
58
|
+
root = project()
|
|
59
|
+
start = time.monotonic()
|
|
60
|
+
p = subprocess.run([sys.executable, "-c", RUN, HERE], cwd=root, capture_output=True, text=True,
|
|
61
|
+
encoding="utf-8", env={**os.environ, "PYTHONIOENCODING": "utf-8"})
|
|
62
|
+
took = time.monotonic() - start
|
|
63
|
+
assert p.stdout.strip(), p.stderr
|
|
64
|
+
r = next(c for c in json.loads(p.stdout)["commits"] if c["subject"].startswith("Fix first_ready"))
|
|
65
|
+
assert r["result"] == "noticed", r
|
|
66
|
+
assert "your tests hang" in r["detail"], r
|
|
67
|
+
assert took < 90, f"the hung run was not stopped early: {took:.0f}s"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_the_cap_follows_the_passing_run():
|
|
71
|
+
cfg = type("Cfg", (), {"test_timeout": 600})()
|
|
72
|
+
assert bite.run_limit(cfg) == 600
|
|
73
|
+
bite.cap_hang(cfg, {"secs": 2.0})
|
|
74
|
+
assert bite.run_limit(cfg) == bite.HANG_FLOOR # a quick suite: the floor
|
|
75
|
+
bite.cap_hang(cfg, {"secs": 40.0})
|
|
76
|
+
assert bite.run_limit(cfg) == 201
|
|
77
|
+
bite.cap_hang(cfg, {"secs": 500.0})
|
|
78
|
+
assert bite.run_limit(cfg) == 600 # never longer than test_timeout
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_the_live_check_stops_a_hang_early_too(monkeypatch):
|
|
82
|
+
root = project()
|
|
83
|
+
monkeypatch.setattr(bite, "HANG_FLOOR", 3)
|
|
84
|
+
subprocess.run([*GIT, "reset", "-q", "--soft", "HEAD~1"], cwd=root, check=True) # the fix, not committed yet
|
|
85
|
+
start = time.monotonic()
|
|
86
|
+
r = live.check(root)
|
|
87
|
+
assert r["status"] == "guarded", r
|
|
88
|
+
assert r["hang"].startswith("stopped after"), r
|
|
89
|
+
assert "they hang" in live.say(r)
|
|
90
|
+
assert time.monotonic() - start < 90
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The free check of one commit took over 8 minutes on Bite's own pull request check, with the log quiet after "free
|
|
2
|
+
check of 1". Two fixes: with --jobs, a failing test run that had the machine to itself anyway isn't run a second
|
|
3
|
+
time, and each test run gets a line in the log saying what it is."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
14
|
+
import recent # noqa: E402
|
|
15
|
+
|
|
16
|
+
RECENT = str(Path(recent.__file__))
|
|
17
|
+
GIT = ["git", "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fake_runs(monkeypatch, secs=0.0):
|
|
21
|
+
calls = []
|
|
22
|
+
|
|
23
|
+
def run(fcfg, folder):
|
|
24
|
+
calls.append(folder)
|
|
25
|
+
time.sleep(secs)
|
|
26
|
+
return {"ok": False, "out": "", "why": "a test failed"}
|
|
27
|
+
monkeypatch.setattr(recent.bite, "run_tests", run)
|
|
28
|
+
monkeypatch.setattr(recent, "JOBS", [2])
|
|
29
|
+
return calls
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_a_failing_run_alone_on_the_machine_is_not_run_twice(monkeypatch):
|
|
33
|
+
calls = fake_runs(monkeypatch)
|
|
34
|
+
assert not recent.run_tests(None, "a")["ok"]
|
|
35
|
+
assert calls == ["a"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_a_failing_run_beside_another_is_run_again_alone(monkeypatch):
|
|
39
|
+
calls = fake_runs(monkeypatch, secs=0.3)
|
|
40
|
+
ts = [threading.Thread(target=recent.run_tests, args=(None, name)) for name in ("a", "b")]
|
|
41
|
+
for t in ts:
|
|
42
|
+
t.start()
|
|
43
|
+
for t in ts:
|
|
44
|
+
t.join()
|
|
45
|
+
assert sorted(calls) == ["a", "a", "b", "b"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_a_failing_run_beside_another_can_leave_the_confirming_to_the_next_run(monkeypatch):
|
|
49
|
+
calls = fake_runs(monkeypatch, secs=0.3)
|
|
50
|
+
out = {}
|
|
51
|
+
ts = [threading.Thread(target=lambda n=name: out.update({n: recent.run_tests(None, n, confirm=False)}))
|
|
52
|
+
for name in ("a", "b")]
|
|
53
|
+
for t in ts:
|
|
54
|
+
t.start()
|
|
55
|
+
for t in ts:
|
|
56
|
+
t.join()
|
|
57
|
+
assert sorted(calls) == ["a", "b"] # not run again here: the flaky rerun, alone, confirms it
|
|
58
|
+
assert out["a"]["shared"] and out["b"]["shared"]
|
|
59
|
+
assert not recent.run_tests(None, "c", alone=True)["ok"]
|
|
60
|
+
assert sorted(calls) == ["a", "b", "c"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_the_log_says_what_each_test_run_is():
|
|
64
|
+
root = Path(tempfile.mkdtemp(prefix="bite_progress_"))
|
|
65
|
+
subprocess.run(["git", "init", "-q"], cwd=root, check=True)
|
|
66
|
+
steps = [("Start", {"price.py": "def total(a, b):\n return a + b + 1\n",
|
|
67
|
+
"test_price.py": "from price import total\n\n\ndef test_ok():\n assert total(1, 1) >= 2\n"}),
|
|
68
|
+
("Fix total adding one too many", {"price.py": "def total(a, b):\n return a + b\n",
|
|
69
|
+
"test_price.py": "from price import total\n\n\n"
|
|
70
|
+
"def test_ok():\n assert total(1, 1) == 2\n"})]
|
|
71
|
+
for subject, files in steps:
|
|
72
|
+
for name, text in files.items():
|
|
73
|
+
(root / name).write_text(text, encoding="utf-8", newline="\n")
|
|
74
|
+
subprocess.run([*GIT, "add", "-A"], cwd=root, check=True)
|
|
75
|
+
subprocess.run([*GIT, "commit", "-q", "-m", subject], cwd=root, check=True)
|
|
76
|
+
p = subprocess.run([sys.executable, RECENT, "--free", "--any"], cwd=root, capture_output=True, text=True,
|
|
77
|
+
encoding="utf-8", env={**os.environ, "PYTHONIOENCODING": "utf-8"})
|
|
78
|
+
log = p.stdout + p.stderr
|
|
79
|
+
assert "running the tests with the change in..." in log, log
|
|
80
|
+
assert "with the change taken out" in log, log
|
|
81
|
+
assert "once more, to rule out a test that fails by luck" in log, log
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|