"""Løkka mot ekte kriterier, med en oppdiktet forbedrer. python demo.py normal går til alle kriteriene er bestått python demo.py maks-runder samme, men med maks tre runder python demo.py budsjett samme, men med et budsjett på 4 000 python demo.py ring forbedreren endrer ingenting Krever Python 3.10 eller nyere og fem filer: kriterier.py og demo.py herfra, og laas.py, test_tempo.py og utkast_tempo.py fra ../test_forst/. Legg dem i samme mappe, eller la mappene ligge ved siden av hverandre slik de gjør i repoet. Kriteriene er ekte. De kjører testene og låsen mot en kopi i en midlertidig mappe, så filene dine blir ikke rørt. Forbedreren er IKKE en AI. Den er et manus med tre forsøk, skrevet for å vise hva løkka gjør når et forsøk endrer testen i stedet for koden, når et forsøk bare løser halve problemet, og når alt er i orden. Kostnaden per forsøk er også oppdiktet. En ekte modell i en slik løkke vil gjøre andre forsøk, i en annen rekkefølge, og kan like gjerne prøve det samme to ganger. Det er derfor ring finnes. Det løkka viser, er stoppreglene, ikke modellen. Exit-kode 0 betyr at alle kriteriene er bestått. Alt annet gir 1, så et skript eller CI ser at løkka stoppet uten å bli ferdig. """ import difflib import os import pathlib import re import shutil import subprocess import sys import tempfile HER = pathlib.Path(__file__).resolve().parent sys.path.insert(0, str(HER)) # så importen virker også med python -I from kriterier import Kriterium, lokke # noqa: E402 # Filene fra del 3 ligger i ../test_forst/ i repoet, men i samme mappe hvis du har lastet dem ned. TEST_FORST = next((m for m in (HER.parent / "test_forst", HER) if (m / "laas.py").exists()), HER) TRENGER = ["laas.py", "test_tempo.py", "utkast_tempo.py"] MILJO = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONDONTWRITEBYTECODE": "1"} MAKS_LINJER = 10 # så stor får endringen i tempo.py være, målt i linjer inn og ut TIDSGRENSE = 5 # sekunder per sjekk. En test som henger, skal bli et nei, ikke en runde uten ende. def kjor(kommando, mappe): """Kjør en sjekk med tidsgrense. Gir None hvis den ikke ble ferdig i tide.""" try: return subprocess.run(kommando, cwd=mappe, capture_output=True, encoding="utf-8", env=MILJO, timeout=TIDSGRENSE) except subprocess.TimeoutExpired: return None # ---- Tre kriterier. Alle kan sjekkes uten tolkning, og alle kan feile. ---------- def tester_bestar(mappe): def sjekk(): r = kjor([sys.executable, "-m", "unittest"], mappe) if r is None: return False, f"brukte over {TIDSGRENSE} s, regnes som nei" linjer = r.stderr.strip().splitlines() totalt = next(int(l.split()[1]) for l in linjer if l.startswith("Ran ")) # Siste linje er «OK» eller «FAILED (failures=1, errors=1)». feilet = sum(int(n) for n in re.findall(r"=(\d+)", linjer[-1])) # Navnene på testene som feiler, så to ulike feil ikke ser like ut. navn = re.findall(r"^(?:FAIL|ERROR): (\w+)", r.stderr, re.M) return r.returncode == 0, "\n".join([f"{totalt - feilet} av {totalt} består"] + [f"feiler: {n}" for n in navn]) return Kriterium("Testene består", sjekk) def tester_uendret(mappe): # Det som IKKE skal endres, er også et kriterium. Uten dette ville et forsøk # som retter fasiten i testen, se like vellykket ut som et som retter koden. def sjekk(): r = kjor([sys.executable, str(TEST_FORST / "laas.py"), "sjekk", str(mappe)], mappe) if r is None: return False, f"brukte over {TIDSGRENSE} s, regnes som nei" forste = r.stdout.strip().splitlines()[0] return r.returncode == 0, forste.removeprefix("FEIL: ").removeprefix("OK: ") return Kriterium("Testene er uendret", sjekk) def liten_endring(mappe, original): def sjekk(): naa = (mappe / "tempo.py").read_text(encoding="utf-8").splitlines() diff = difflib.unified_diff(original, naa, lineterm="") endret = sum(1 for l in diff if l[:1] in "+-" and not l.startswith(("+++", "---"))) return endret <= MAKS_LINJER, f"{endret} linjer endret, grensa er {MAKS_LINJER}" return Kriterium("Endringen er liten", sjekk) # ---- Forbedreren: et manus, ikke en modell. ------------------------------------- def lag_forbedrer(mappe): test = mappe / "test_tempo.py" kode = mappe / "tempo.py" ekte_test = test.read_text(encoding="utf-8") def bytt(fil, gammel, ny): tekst = fil.read_text(encoding="utf-8") assert gammel in tekst, gammel fil.write_text(tekst.replace(gammel, ny), encoding="utf-8") def endre_testen(): # Den korteste veien til grønt: fasiten tilpasses det koden gir. bytt(test, 'tempo(3, "14:59"), "5:00")', 'tempo(3, "14:59"), "4:60")') bytt(test, "assertRaises(ValueError)", "assertRaises(ZeroDivisionError)") return "endret fasiten i testen", 1800 def halvveis(): test.write_text(ekte_test, encoding="utf-8") # testen tilbake slik den var bytt(kode, "per_km = (timer * 3600 + minutter * 60 + sekunder) / km", "per_km = round((timer * 3600 + minutter * 60 + sekunder) / km)") bytt(kode, 'f"{int(per_km // 60)}:{round(per_km % 60):02d}"', 'f"{per_km // 60}:{per_km % 60:02d}"') return "satte testen tilbake og rettet avrundinga", 2400 def ferdig(): bytt(kode, " deler = [int(d)", " if km <= 0:\n raise ValueError(\"Distansen må være større enn null\")\n" " deler = [int(d)") return "la til feil for null distanse", 2100 manus = iter([endre_testen, halvveis, ferdig]) def forbedre(resultater): steg = next(manus, None) if steg is None: print(" -> forbedre: har ikke flere forsøk") return 0 hva, kostnad = steg() print(f" -> forbedre: {hva} (kostnad {kostnad}, oppdiktet)") return kostnad return forbedre def ingen_endring(resultater): print(" -> forbedre: prøvde, men endret ingenting (kostnad 1500, oppdiktet)") return 1500 SCENARIER = { "normal": dict(maks_runder=6, budsjett=10_000), "maks-runder": dict(maks_runder=3, budsjett=10_000), "budsjett": dict(maks_runder=6, budsjett=4_000), "ring": dict(maks_runder=6, budsjett=10_000), } def main(): sys.stdout.reconfigure(encoding="utf-8") navn = sys.argv[1] if len(sys.argv) > 1 else "normal" if navn not in SCENARIER: print(__doc__) sys.exit(2) mangler = [f for f in TRENGER if not (TEST_FORST / f).exists()] if mangler: print(f"Finner ikke {', '.join(mangler)}. Legg filene i samme mappe som demo.py.") sys.exit(2) with tempfile.TemporaryDirectory() as tmp: mappe = pathlib.Path(tmp) shutil.copy(TEST_FORST / "utkast_tempo.py", mappe / "tempo.py") shutil.copy(TEST_FORST / "test_tempo.py", mappe / "test_tempo.py") # Låsen lages her, av den urørte testfila, så du ikke trenger tester.laas. subprocess.run([sys.executable, str(TEST_FORST / "laas.py"), "lag", str(mappe)], check=True, capture_output=True, env=MILJO, timeout=TIDSGRENSE) print("Kopierte utkastet og testene til en midlertidig mappe, og låste testene der.") original = (mappe / "tempo.py").read_text(encoding="utf-8").splitlines() kriterier = [tester_bestar(mappe), tester_uendret(mappe), liten_endring(mappe, original)] forbedre = ingen_endring if navn == "ring" else lag_forbedrer(mappe) stopp = lokke(kriterier, forbedre, **SCENARIER[navn]) print(f"\nSTOPP: {stopp.grunn} etter {stopp.runder} runder, brukt {stopp.brukt}") sys.exit(0 if stopp.grunn == "alle bestått" else 1) if __name__ == "__main__": main()