test: add unified DOSBox-X milestone runner
This commit is contained in:
@@ -7,6 +7,9 @@
|
|||||||
!.qemu/share/*.c
|
!.qemu/share/*.c
|
||||||
.qemu/share/fec/
|
.qemu/share/fec/
|
||||||
|
|
||||||
|
# Reproducible DOSBox-X/Open Watcom development cache and ephemeral runs
|
||||||
|
.dosboxx/
|
||||||
|
|
||||||
.qemu/*.qcow2
|
.qemu/*.qcow2
|
||||||
.qemu/*.img
|
.qemu/*.img
|
||||||
.qemu/*.iso
|
.qemu/*.iso
|
||||||
|
|||||||
@@ -18,11 +18,16 @@ VM 자동화 **명령 목록과 플래그는 문서가 아니라 CLI가 규범**
|
|||||||
```powershell
|
```powershell
|
||||||
uv run ferro-vm --help
|
uv run ferro-vm --help
|
||||||
uv run ferro-vm <command> --help
|
uv run ferro-vm <command> --help
|
||||||
|
uv run ferro-test --help
|
||||||
```
|
```
|
||||||
|
|
||||||
## 검증 규칙
|
## 검증 규칙
|
||||||
|
|
||||||
- **실행 검증은 QEMU FreeDOS 내부에서만 한다.** DOSBox는 쓰지 않는다.
|
- 개발 중 빠른 회귀 검사는 `uv run ferro-test run --through <milestone>`로
|
||||||
|
DOSBox-X에서 수행한다.
|
||||||
|
이 경로도 컴파일러 A를 DOS 내부 Open Watcom으로 매번 새로 빌드한다.
|
||||||
|
- **마일스톤의 최종 공식 검증은 QEMU FreeDOS 내부에서만 한다.** DOSBox-X 결과는
|
||||||
|
개발용 smoke test이며 완료 게이트를 대체하지 않는다.
|
||||||
- 컴파일러 A와 생성 C 모두 VM 안의 Open Watcom으로 컴파일한다.
|
- 컴파일러 A와 생성 C 모두 VM 안의 Open Watcom으로 컴파일한다.
|
||||||
컴파일러 A와 bits16은 `WCL`, bits32 생성 C는 `WCL386`.
|
컴파일러 A와 bits16은 `WCL`, bits32 생성 C는 `WCL386`.
|
||||||
- **호스트에서 컴파일하지 않는다.** WSL이나 호스트 C 컴파일러 결과는 정식 검증으로
|
- **호스트에서 컴파일하지 않는다.** WSL이나 호스트 C 컴파일러 결과는 정식 검증으로
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ description = "Ferro language compiler and QEMU development automation"
|
|||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"onnxruntime>=1.28.0",
|
"onnxruntime>=1.28.0",
|
||||||
|
"pytest>=9.0.0",
|
||||||
"rapidocr>=3.9.2",
|
"rapidocr>=3.9.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
ferro-vm = "ferrolang_vm.cli:main"
|
ferro-vm = "ferrolang_vm.cli:main"
|
||||||
|
ferro-test = "ferrolang_vm.test_cli:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Reproducible DOSBox-X/Open Watcom development test backend."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .daemon import ROOT
|
||||||
|
from .suite import Case
|
||||||
|
|
||||||
|
|
||||||
|
CACHE = ROOT / ".dosboxx"
|
||||||
|
LOCK_PATH = ROOT / "tools" / "toolchains" / "dosboxx.lock.json"
|
||||||
|
RUNS = CACHE / "runs"
|
||||||
|
|
||||||
|
|
||||||
|
class DosboxError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _lock() -> dict[str, object]:
|
||||||
|
return json.loads(LOCK_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
while chunk := source.read(1024 * 1024):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _download(name: str, spec: dict[str, object]) -> Path:
|
||||||
|
downloads = CACHE / "downloads"
|
||||||
|
downloads.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = downloads / Path(str(spec["url"])).name
|
||||||
|
expected = str(spec["sha256"]).lower()
|
||||||
|
if target.is_file() and _sha256(target) == expected:
|
||||||
|
return target
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
partial = target.with_suffix(target.suffix + ".part")
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
print(f"ferro-test: downloading {name} {spec['version']}...")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(str(spec["url"])) as response, partial.open("wb") as output:
|
||||||
|
shutil.copyfileobj(response, output, length=1024 * 1024)
|
||||||
|
except Exception:
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
actual = _sha256(partial)
|
||||||
|
if actual != expected:
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
raise DosboxError(f"{name} SHA-256 mismatch: expected {expected}, got {actual}")
|
||||||
|
partial.replace(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_extract(archive: Path, destination: Path) -> None:
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = Path(tempfile.mkdtemp(prefix=f".{destination.name}-", dir=destination.parent))
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(archive) as bundle:
|
||||||
|
root = temporary.resolve()
|
||||||
|
for member in bundle.infolist():
|
||||||
|
target = (temporary / member.filename).resolve()
|
||||||
|
if target != root and root not in target.parents:
|
||||||
|
raise DosboxError(f"unsafe archive member: {member.filename}")
|
||||||
|
bundle.extractall(temporary)
|
||||||
|
if destination.exists():
|
||||||
|
shutil.rmtree(destination)
|
||||||
|
temporary.replace(destination)
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(temporary, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _required_paths(lock: dict[str, object]) -> tuple[Path, Path, list[Path]]:
|
||||||
|
dosbox_spec = lock["dosboxx"]
|
||||||
|
watcom_spec = lock["open_watcom"]
|
||||||
|
assert isinstance(dosbox_spec, dict) and isinstance(watcom_spec, dict)
|
||||||
|
dosbox = CACHE / "dosbox-x" / str(dosbox_spec["executable"])
|
||||||
|
watcom = CACHE / "watcom"
|
||||||
|
required = [watcom / str(item) for item in watcom_spec["required"]]
|
||||||
|
return dosbox, watcom, required
|
||||||
|
|
||||||
|
|
||||||
|
def setup(*, accept_watcom_license: bool = False) -> tuple[Path, Path]:
|
||||||
|
if os.name != "nt":
|
||||||
|
raise DosboxError("the DOSBox-X development backend currently supports Windows only")
|
||||||
|
lock = _lock()
|
||||||
|
dosbox, watcom, watcom_required = _required_paths(lock)
|
||||||
|
marker = CACHE / "SETUP.OK"
|
||||||
|
lock_hash = _sha256(LOCK_PATH)
|
||||||
|
if (marker.is_file() and marker.read_text(encoding="ascii").strip() == lock_hash
|
||||||
|
and dosbox.is_file() and all(path.is_file() for path in watcom_required)):
|
||||||
|
return dosbox, watcom
|
||||||
|
if not accept_watcom_license:
|
||||||
|
raise DosboxError(
|
||||||
|
"Open Watcom is distributed under the Sybase Open Watcom Public License. "
|
||||||
|
"Review tools/toolchains/dosboxx.lock.json and rerun "
|
||||||
|
"`uv run ferro-test setup --accept-watcom-license`."
|
||||||
|
)
|
||||||
|
dosbox_spec = lock["dosboxx"]
|
||||||
|
watcom_spec = lock["open_watcom"]
|
||||||
|
assert isinstance(dosbox_spec, dict) and isinstance(watcom_spec, dict)
|
||||||
|
_safe_extract(_download("DOSBox-X", dosbox_spec), CACHE / "dosbox-x")
|
||||||
|
_safe_extract(_download("Open Watcom", watcom_spec), watcom)
|
||||||
|
dosbox, watcom, watcom_required = _required_paths(lock)
|
||||||
|
missing = [str(path.relative_to(CACHE)) for path in [dosbox, *watcom_required]
|
||||||
|
if not path.is_file()]
|
||||||
|
if missing:
|
||||||
|
raise DosboxError("toolchain archive is missing: " + ", ".join(missing))
|
||||||
|
marker.write_text(lock_hash + "\n", encoding="ascii")
|
||||||
|
return dosbox, watcom
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tools() -> tuple[Path, Path]:
|
||||||
|
lock = _lock()
|
||||||
|
dosbox, watcom, required = _required_paths(lock)
|
||||||
|
if not dosbox.is_file() or not all(path.is_file() for path in required):
|
||||||
|
raise DosboxError("toolchain is not installed; run `uv run ferro-test setup`")
|
||||||
|
return dosbox, watcom
|
||||||
|
|
||||||
|
|
||||||
|
def _batch(cases: list[Case], *, show_dos: bool, trace_dos: bool) -> str:
|
||||||
|
lines = [
|
||||||
|
"@echo off", "if not exist RESULTS md RESULTS", "if not exist OUT md OUT",
|
||||||
|
"set WATCOM=W:", "set INCLUDE=W:\\H",
|
||||||
|
"set LIB=W:\\LIB286\\DOS;W:\\LIB286;W:\\LIB386\\DOS;W:\\LIB386",
|
||||||
|
"call BUILD.BAT", "if not exist BUILD.OK goto BUILDFAIL",
|
||||||
|
"echo PASS>RESULTS\\BUILD.RES",
|
||||||
|
]
|
||||||
|
for index, case in enumerate(cases):
|
||||||
|
key = f"C{index:03d}"
|
||||||
|
command = case.command
|
||||||
|
if not trace_dos:
|
||||||
|
command += f" > RESULTS\\{key}.LOG"
|
||||||
|
lines.append(command)
|
||||||
|
if case.expect_success:
|
||||||
|
lines.extend([
|
||||||
|
f"if errorlevel 1 goto {key}F", f"echo PASS>RESULTS\\{key}.RES",
|
||||||
|
f"goto {key}D", f":{key}F", f"echo FAIL>RESULTS\\{key}.RES", f":{key}D",
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
lines.extend([
|
||||||
|
f"if errorlevel 1 goto {key}P", f"echo FAIL>RESULTS\\{key}.RES",
|
||||||
|
f"goto {key}D", f":{key}P", f"echo PASS>RESULTS\\{key}.RES", f":{key}D",
|
||||||
|
])
|
||||||
|
lines.extend([
|
||||||
|
"goto FINISH", ":BUILDFAIL", "echo FAIL>RESULTS\\BUILD.RES", ":FINISH",
|
||||||
|
"echo DONE>RUN.OK", *(["pause"] if show_dos else []), "exit", "",
|
||||||
|
])
|
||||||
|
return "\r\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SuiteRun:
|
||||||
|
root: Path
|
||||||
|
cases: list[Case]
|
||||||
|
keep: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fec(self) -> Path:
|
||||||
|
return self.root / "FEC"
|
||||||
|
|
||||||
|
def _key(self, case: Case) -> str:
|
||||||
|
return f"C{self.cases.index(case):03d}"
|
||||||
|
|
||||||
|
def result(self, case: Case | None = None) -> str:
|
||||||
|
name = "BUILD" if case is None else self._key(case)
|
||||||
|
path = self.fec / "RESULTS" / f"{name}.RES"
|
||||||
|
return path.read_text(encoding="ascii").strip() if path.is_file() else "MISSING"
|
||||||
|
|
||||||
|
def log(self, case: Case | None = None) -> str:
|
||||||
|
name = "BUILD" if case is None else self._key(case)
|
||||||
|
path = self.fec / "RESULTS" / f"{name}.LOG"
|
||||||
|
content = path.read_text(encoding="utf-8", errors="replace") if path.is_file() else ""
|
||||||
|
if content:
|
||||||
|
return content
|
||||||
|
errors = sorted(self.fec.glob("*.ERR"))
|
||||||
|
if errors:
|
||||||
|
return "\n".join(p.read_text(encoding="utf-8", errors="replace") for p in errors)
|
||||||
|
console = self.root / "CONSOLE.LOG"
|
||||||
|
return console.read_text(encoding="utf-8", errors="replace") if console.is_file() else ""
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
if not self.keep:
|
||||||
|
shutil.rmtree(self.root, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def run_suite(cases: list[Case], *, keep: bool = False, show_dos: bool = False,
|
||||||
|
trace_dos: bool = False) -> SuiteRun:
|
||||||
|
dosbox, watcom = resolve_tools()
|
||||||
|
RUNS.mkdir(parents=True, exist_ok=True)
|
||||||
|
run_root = Path(tempfile.mkdtemp(prefix="suite-", dir=RUNS))
|
||||||
|
result = SuiteRun(run_root, cases, keep)
|
||||||
|
fec = result.fec
|
||||||
|
try:
|
||||||
|
shutil.copytree(ROOT / "fec" / "src", fec / "SRC")
|
||||||
|
shutil.copytree(ROOT / "fec" / "std", fec / "STD")
|
||||||
|
shutil.copytree(ROOT / "fec" / "tests", fec / "TESTS")
|
||||||
|
shutil.copy2(ROOT / "fec" / "build-dos.bat", fec / "BUILD.BAT")
|
||||||
|
console = run_root / "CONSOLE.LOG"
|
||||||
|
config = run_root / "DOSBOX.CON"
|
||||||
|
config.write_text(
|
||||||
|
f"[log]\nlogfile={console}\n[dosbox]\nlog console=quiet\n",
|
||||||
|
encoding="ascii",
|
||||||
|
)
|
||||||
|
(fec / "RUN.BAT").write_text(
|
||||||
|
_batch(cases, show_dos=show_dos, trace_dos=trace_dos),
|
||||||
|
encoding="ascii", newline="",
|
||||||
|
)
|
||||||
|
command = [str(dosbox)]
|
||||||
|
if not show_dos:
|
||||||
|
command.append("-silent")
|
||||||
|
command.extend([
|
||||||
|
"-fastlaunch", "-conf", str(config),
|
||||||
|
"-c", f'mount C "{run_root}"', "-c", f'mount W "{watcom}" -ro',
|
||||||
|
"-c", "C:", "-c", "cd \\FEC", "-c", "RUN.BAT",
|
||||||
|
])
|
||||||
|
completed = subprocess.run(command, check=False, timeout=300)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise DosboxError(f"DOSBox-X exited with status {completed.returncode}")
|
||||||
|
if not (fec / "RUN.OK").is_file():
|
||||||
|
raise DosboxError("DOSBox-X did not complete the test batch")
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
result.keep = True
|
||||||
|
raise
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Explicit M1--M3 commands and expectations from TEST-DOS.BAT."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .suite import Case
|
||||||
|
|
||||||
|
|
||||||
|
def _c(milestone: int, name: str, command: str, ok: bool = True) -> Case:
|
||||||
|
return Case(f"m{milestone}-{name}", milestone, command, ok)
|
||||||
|
|
||||||
|
|
||||||
|
M1_M3_CASES: list[Case] = []
|
||||||
|
for _name in ("basic", "literals", "keybuilt", "v012form"):
|
||||||
|
M1_M3_CASES.append(_c(1, f"{_name}-parse", f"FEC.EXE --dump-ast TESTS\\PASS\\{_name.upper()}.FE"))
|
||||||
|
for _name in ("core", "fmt", "io", "list", "map", "mem", "str", "sys"):
|
||||||
|
M1_M3_CASES.append(_c(1, f"std-{_name}-parse", f"FEC.EXE --dump-ast STD\\{_name.upper()}.FE"))
|
||||||
|
for _name in ("misssemi", "unclcomm", "logical"):
|
||||||
|
M1_M3_CASES.append(_c(1, f"{_name}-reject", f"FEC.EXE --dump-ast TESTS\\FAIL\\{_name.upper()}.FE", False))
|
||||||
|
|
||||||
|
for _name in ("hello", "scopes"):
|
||||||
|
_upper = _name.upper()
|
||||||
|
M1_M3_CASES.extend([
|
||||||
|
_c(2, f"{_name}-emit", f"FEC.EXE --target=bits32 --emit-c TESTS\\M2\\{_upper}.FE -o TESTS\\M2\\{_upper}.C"),
|
||||||
|
_c(2, f"{_name}-build", f"WCL386 -q -za -bt=dos -fe=TESTS\\M2\\{_upper}.EXE TESTS\\M2\\{_upper}.C"),
|
||||||
|
_c(2, f"{_name}-run", f"TESTS\\M2\\{_upper}.EXE"),
|
||||||
|
])
|
||||||
|
M1_M3_CASES.extend([
|
||||||
|
_c(2, "castwhil-emit", "FEC.EXE --target=bits16 --emit-c TESTS\\M2\\CASTWHIL.FE -o TESTS\\M2\\CAST16.C"),
|
||||||
|
_c(2, "castwhil-build", "WCL -q -za -bt=dos -fe=TESTS\\M2\\CAST16.EXE TESTS\\M2\\CAST16.C"),
|
||||||
|
_c(2, "castwhil-run", "TESTS\\M2\\CAST16.EXE"),
|
||||||
|
])
|
||||||
|
_m2_outputs = {
|
||||||
|
"bad-cond": "BAD-CO", "bad-cast": "BAD-CA", "bad-asgn": "BAD-AS",
|
||||||
|
"bad-unk": "BAD-UN", "bad-ari": "BAD-AR", "bad-type": "BAD-TY",
|
||||||
|
"bad-ret": "BAD-RE", "bad-unit": "BAD-UI", "bad-void": "BAD-VO",
|
||||||
|
}
|
||||||
|
for _name, _output in _m2_outputs.items():
|
||||||
|
M1_M3_CASES.append(_c(2, f"{_name}-reject", "FEC.EXE --target=bits32 --emit-c "
|
||||||
|
f"TESTS\\M2\\{_name.upper()}.FE -o TESTS\\M2\\{_output}.C", False))
|
||||||
|
|
||||||
|
def _m3_runtime(name: str) -> list[Case]:
|
||||||
|
upper = name.upper()
|
||||||
|
return [
|
||||||
|
_c(3, f"{name}-emit", f"FEC.EXE --target=bits32 --emit-c TESTS\\M3\\{upper}.FE -o TESTS\\M3\\{upper}.C"),
|
||||||
|
_c(3, f"{name}-build", f"WCL386 -q -za -bt=dos -fe=TESTS\\M3\\{upper}.EXE TESTS\\M3\\{upper}.C"),
|
||||||
|
_c(3, f"{name}-run", f"TESTS\\M3\\{upper}.EXE"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
for _name in ("struct", "enum", "array", "mutable"):
|
||||||
|
M1_M3_CASES.extend(_m3_runtime(_name))
|
||||||
|
for _name in ("bad-mlet", "bad-shwr"):
|
||||||
|
M1_M3_CASES.append(_c(3, f"{_name}-reject", "FEC.EXE --target=bits32 --emit-c "
|
||||||
|
f"TESTS\\M3\\{_name.upper()}.FE -o TESTS\\M3\\{_name.upper()}.C", False))
|
||||||
|
for _name in ("str", "for", "nested", "char", "arrayctx"):
|
||||||
|
M1_M3_CASES.extend(_m3_runtime(_name))
|
||||||
|
for _name in ("bounds", "slcbound"):
|
||||||
|
_upper = _name.upper()
|
||||||
|
M1_M3_CASES.extend([
|
||||||
|
_c(3, f"{_name}-emit", f"FEC.EXE --target=bits32 --emit-c TESTS\\M3\\{_upper}.FE -o TESTS\\M3\\{_upper}.C"),
|
||||||
|
_c(3, f"{_name}-build", f"WCL386 -q -za -bt=dos -fe=TESTS\\M3\\{_upper}.EXE TESTS\\M3\\{_upper}.C"),
|
||||||
|
_c(3, f"{_name}-trap", f"TESTS\\M3\\{_upper}.EXE", False),
|
||||||
|
])
|
||||||
|
M1_M3_CASES.extend([
|
||||||
|
_c(3, "bounds-no-checks-emit", "FEC.EXE --target=bits32 --no-checks --emit-c TESTS\\M3\\BOUNDS.FE -o TESTS\\M3\\BOUNDS-N.C"),
|
||||||
|
_c(3, "bounds-no-checks-build", "WCL386 -q -za -bt=dos -fe=TESTS\\M3\\BOUNDS-N.EXE TESTS\\M3\\BOUNDS-N.C"),
|
||||||
|
])
|
||||||
|
for _name in ("badfld", "badmat", "badarr", "badcycle", "badstr", "badchar", "badfield", "badindex"):
|
||||||
|
M1_M3_CASES.append(_c(3, f"{_name}-reject", "FEC.EXE --target=bits32 --emit-c "
|
||||||
|
f"TESTS\\M3\\{_name.upper()}.FE -o TESTS\\M3\\{_name.upper()}.C", False))
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Explicit M4--M5 cases from TEST-DOS.BAT and M6 fixture expectations.
|
||||||
|
|
||||||
|
Only commands and their expected status live here; the ``.fe`` fixtures stay
|
||||||
|
under ``fec/tests`` and are copied by the runner.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .suite import Case
|
||||||
|
|
||||||
|
|
||||||
|
def _c(milestone: int, name: str, command: str, ok: bool) -> Case:
|
||||||
|
return Case(f"m{milestone}-{name}", milestone, command, ok)
|
||||||
|
|
||||||
|
|
||||||
|
M4_M6_CASES: list[Case] = [
|
||||||
|
_c(4, "format", "FEC.EXE --target=bits32 --emit-c TESTS\\M4\\FORMAT.FE -o TESTS\\M4\\FORMAT.C", True),
|
||||||
|
_c(4, "format-build", "WCL386 -q -za -wx -wcd=202 -bt=dos -fe=TESTS\\M4\\FORMAT.EXE TESTS\\M4\\FORMAT.C", True),
|
||||||
|
_c(4, "format-run", "TESTS\\M4\\FORMAT.EXE", True),
|
||||||
|
_c(4, "try-fpr", "FEC.EXE --target=bits32 --emit-c TESTS\\M4\\TRY-FPR.FE -o TESTS\\M4\\TRY-FPR.C", True),
|
||||||
|
_c(4, "try-fpr-build", "WCL386 -q -za -wx -wcd=202 -bt=dos -fe=TESTS\\M4\\TRY-FPR.EXE TESTS\\M4\\TRY-FPR.C", True),
|
||||||
|
_c(4, "try-fpr-run", "TESTS\\M4\\TRY-FPR.EXE", True),
|
||||||
|
_c(4, "prop", "FEC.EXE --target=bits32 --emit-c TESTS\\M4\\PROP.FE -o TESTS\\M4\\PROP.C", True),
|
||||||
|
_c(4, "prop-build", "WCL386 -q -za -wx -wcd=202 -bt=dos -fe=TESTS\\M4\\PROP.EXE TESTS\\M4\\PROPTEST.C", True),
|
||||||
|
_c(4, "prop-run", "TESTS\\M4\\PROP.EXE", True),
|
||||||
|
]
|
||||||
|
|
||||||
|
_m4_outputs = {"bad-type": "BAD-TYP", "bad-writ": "BAD-WRI"}
|
||||||
|
for _name in ("bad-ari", "bad-verb", "bad-run", "bad-type", "bad-try", "bad-writ", "bad-bufw", "bad-many", "bad-open", "bad-cls"):
|
||||||
|
_output = _m4_outputs.get(_name, _name.upper())
|
||||||
|
M4_M6_CASES.append(_c(4, _name, "FEC.EXE --target=bits32 --emit-c "
|
||||||
|
f"TESTS\\M4\\{_name.upper()}.FE -o TESTS\\M4\\{_output}.C", False))
|
||||||
|
|
||||||
|
for _name in ("defer", "owned"):
|
||||||
|
M4_M6_CASES.append(_c(5, _name, "FEC.EXE --target=bits32 --emit-c "
|
||||||
|
f"TESTS\\M5\\{_name.upper()}.FE -o TESTS\\M5\\{_name.upper()}.C", True))
|
||||||
|
for _name in ("bad-move", "bad-dest", "bad-drop", "bad-dbl", "bad-cond", "bad-proj", "bad-clos", "bad-loop"):
|
||||||
|
_output = "BAD-DES" if _name == "bad-dest" else _name.upper()
|
||||||
|
M4_M6_CASES.append(_c(5, _name, "FEC.EXE --target=bits32 --emit-c "
|
||||||
|
f"TESTS\\M5\\{_name.upper()}.FE -o TESTS\\M5\\{_output}.C", False))
|
||||||
|
M4_M6_CASES += [
|
||||||
|
_c(5, "runtime", "FEC.EXE --target=bits32 --emit-c TESTS\\M5\\RUNTIME.FE -o TESTS\\M5\\RUNT-G.C", True),
|
||||||
|
_c(5, "runtime-build", "WCL386 -q -za -bt=dos -dmalloc=m5_malloc -dfree=m5_free -fe=TESTS\\M5\\RUNTIME.EXE TESTS\\M5\\RUNT-G.C TESTS\\M5\\RUNTIME.C", True),
|
||||||
|
_c(5, "runtime-run", "TESTS\\M5\\RUNTIME.EXE", True),
|
||||||
|
]
|
||||||
|
|
||||||
|
for _name in ("badarg", "badbinit", "badbrmov", "baddefer", "badfld", "badglob", "badgmut", "badinv", "badlocsl", "badloop", "badmove", "badmut", "badmut2", "badptr", "badret", "badrfld", "badridx", "badscop", "badself", "badshwr", "badslfld", "badtwo", "badup", "badweak"):
|
||||||
|
M4_M6_CASES.append(_c(6, _name, f"FEC.EXE --check TESTS\\M6\\{_name.upper()}.FE", False))
|
||||||
|
for _name in ("okbranch", "okdefer", "okglobcp", "oklast", "okr8free", "okr8join", "okr8meth", "okr8stat", "okrebor", "okrtlast", "okshare", "okslreb", "okstatic", "oktemp", "oktrim", "okwcall"):
|
||||||
|
M4_M6_CASES.append(_c(6, _name, f"FEC.EXE --target=bits32 --emit-c -o OUT\\{_name.upper()}.C TESTS\\M6\\{_name.upper()}.FE", True))
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Shared types and selection for the explicit milestone registries."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Case:
|
||||||
|
id: str
|
||||||
|
milestone: int
|
||||||
|
command: str
|
||||||
|
expect_success: bool
|
||||||
|
|
||||||
|
|
||||||
|
def all_cases(*, through: int = 6, only: int | None = None) -> list[Case]:
|
||||||
|
from .registry_m1_m3 import M1_M3_CASES
|
||||||
|
from .registry_m4_m6 import M4_M6_CASES
|
||||||
|
|
||||||
|
cases = [*M1_M3_CASES, *M4_M6_CASES]
|
||||||
|
if only is not None:
|
||||||
|
return [case for case in cases if case.milestone == only]
|
||||||
|
return [case for case in cases if case.milestone <= through]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Developer test entry point backed by a disposable DOSBox-X run."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .dosboxx import DosboxError, setup
|
||||||
|
|
||||||
|
|
||||||
|
MILESTONES = tuple(f"m{number}" for number in range(1, 7))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="ferro-test",
|
||||||
|
description="Fast Ferro development tests in DOSBox-X/Open Watcom.",
|
||||||
|
)
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
prepare = commands.add_parser("setup", help="download and verify pinned development tools")
|
||||||
|
prepare.add_argument("--accept-watcom-license", action="store_true",
|
||||||
|
help="confirm acceptance of the Sybase Open Watcom Public License")
|
||||||
|
run = commands.add_parser("run", help="build once and run milestone pytest cases")
|
||||||
|
selection = run.add_mutually_exclusive_group()
|
||||||
|
selection.add_argument("--through", choices=MILESTONES, default="m6",
|
||||||
|
help="run cumulatively through this milestone (default: m6)")
|
||||||
|
selection.add_argument("--only", choices=MILESTONES,
|
||||||
|
help="run only this milestone's cases")
|
||||||
|
run.add_argument("-v", "--verbose", action="store_true", help="show every pytest case")
|
||||||
|
run.add_argument("--keep-failed", action="store_true",
|
||||||
|
help="keep the disposable DOS filesystem after failures")
|
||||||
|
run.add_argument("--show-dos", action="store_true",
|
||||||
|
help="show DOSBox-X and wait for a key before closing")
|
||||||
|
run.add_argument("--dos-log", action="store_true",
|
||||||
|
help="print the captured DOS console after the run")
|
||||||
|
run.add_argument("--trace-dos", action="store_true",
|
||||||
|
help="do not redirect case command output")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
if args.command == "setup":
|
||||||
|
dosbox, watcom = setup(accept_watcom_license=args.accept_watcom_license)
|
||||||
|
print(f"DOSBox-X: {dosbox}")
|
||||||
|
print(f"Open Watcom: {watcom}")
|
||||||
|
return 0
|
||||||
|
if args.only:
|
||||||
|
os.environ["FERRO_TEST_ONLY"] = args.only
|
||||||
|
else:
|
||||||
|
os.environ["FERRO_TEST_THROUGH"] = args.through
|
||||||
|
for enabled, name in (
|
||||||
|
(args.keep_failed, "FERRO_TEST_KEEP_FAILED"),
|
||||||
|
(args.show_dos, "FERRO_TEST_SHOW_DOS"),
|
||||||
|
(args.trace_dos, "FERRO_TEST_TRACE_DOS"),
|
||||||
|
(args.dos_log, "FERRO_TEST_DOS_LOG"),
|
||||||
|
):
|
||||||
|
if enabled:
|
||||||
|
os.environ[name] = "1"
|
||||||
|
import pytest
|
||||||
|
test_file = os.fspath(
|
||||||
|
Path(__file__).resolve().parents[2] / "tools" / "tests" / "test_milestones_dosboxx.py"
|
||||||
|
)
|
||||||
|
pytest_args = [test_file, "--tb=short", "-v" if args.verbose else "-q"]
|
||||||
|
if args.dos_log:
|
||||||
|
pytest_args.append("-s")
|
||||||
|
return int(pytest.main(pytest_args))
|
||||||
|
except (DosboxError, ValueError) as exc:
|
||||||
|
print(f"ferro-test: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+29
-3
@@ -2,12 +2,37 @@
|
|||||||
|
|
||||||
## Host support
|
## Host support
|
||||||
|
|
||||||
Automation currently supports **Windows 10/11 only**. It requires `uv`, QEMU
|
Automation currently supports **Windows 10/11 only**. The fast development loop
|
||||||
with WHPX support, and `ffmpeg.exe` on `PATH`. The Python implementation uses
|
requires only `uv`; it downloads pinned DOSBox-X and Open Watcom DOS releases.
|
||||||
portable APIs where possible, but other hosts are not supported yet.
|
The final milestone gate additionally requires QEMU with WHPX support and
|
||||||
|
`ffmpeg.exe` on `PATH`. Other hosts are not supported yet.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run ferro-test setup --accept-watcom-license
|
||||||
|
uv run ferro-test run --through m6 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup` reads `tools/toolchains/dosboxx.lock.json`, downloads the exact official
|
||||||
|
archives, verifies their SHA-256 hashes, and extracts them under ignored
|
||||||
|
`.dosboxx/`. Review the Open Watcom license referenced by the lock file before
|
||||||
|
accepting it. Archives and installed tools are deliberately not committed.
|
||||||
|
|
||||||
|
Each `run` creates a disposable DOS drive, copies the current compiler, standard
|
||||||
|
library, and fixtures, then builds `FEC.EXE` once inside DOS with Open Watcom.
|
||||||
|
All selected milestone commands execute sequentially in that same DOSBox-X
|
||||||
|
instance, while pytest reports every emit, Watcom build, runtime, and rejection
|
||||||
|
check separately. Thus stale QEMU binaries cannot make the test pass.
|
||||||
|
|
||||||
|
`--through m6` runs cumulatively from M1; `--only m6` selects one milestone.
|
||||||
|
Use `--keep-failed` to preserve a failed drive under `.dosboxx/runs/`,
|
||||||
|
`--dos-log` to print the captured DOS console, `--trace-dos` to disable command
|
||||||
|
output redirection, and `--show-dos` to keep the GUI open until a key is pressed.
|
||||||
|
|
||||||
|
This is the quick development smoke test. Run the QEMU/FreeDOS workflow below
|
||||||
|
for the authoritative milestone completion gate.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
uv run ferro-vm start
|
uv run ferro-vm start
|
||||||
uv run ferro-vm status
|
uv run ferro-vm status
|
||||||
@@ -18,6 +43,7 @@ The command list lives in the CLI itself, not in this file:
|
|||||||
```powershell
|
```powershell
|
||||||
uv run ferro-vm --help
|
uv run ferro-vm --help
|
||||||
uv run ferro-vm <command> --help
|
uv run ferro-vm <command> --help
|
||||||
|
uv run ferro-test --help
|
||||||
```
|
```
|
||||||
|
|
||||||
Working rules, verification gates, and DOS build traps are in `AGENTS.md`.
|
Working rules, verification gates, and DOS build traps are in `AGENTS.md`.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ferrolang_vm.dosboxx import SuiteRun, run_suite
|
||||||
|
from ferrolang_vm.suite import Case, all_cases
|
||||||
|
|
||||||
|
|
||||||
|
def _number(name: str) -> int:
|
||||||
|
if not name.startswith("m") or not name[1:].isdigit():
|
||||||
|
raise ValueError(f"invalid milestone: {name}")
|
||||||
|
value = int(name[1:])
|
||||||
|
if value not in range(1, 7):
|
||||||
|
raise ValueError(f"unsupported milestone: {name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
ONLY = os.environ.get("FERRO_TEST_ONLY")
|
||||||
|
CASES = all_cases(
|
||||||
|
through=_number(os.environ.get("FERRO_TEST_THROUGH", "m6")),
|
||||||
|
only=_number(ONLY) if ONLY else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def suite_run() -> SuiteRun:
|
||||||
|
run = run_suite(
|
||||||
|
CASES,
|
||||||
|
keep=os.environ.get("FERRO_TEST_KEEP_FAILED") == "1",
|
||||||
|
show_dos=os.environ.get("FERRO_TEST_SHOW_DOS") == "1",
|
||||||
|
trace_dos=os.environ.get("FERRO_TEST_TRACE_DOS") == "1",
|
||||||
|
)
|
||||||
|
yield run
|
||||||
|
if os.environ.get("FERRO_TEST_DOS_LOG") == "1":
|
||||||
|
console = run.root / "CONSOLE.LOG"
|
||||||
|
if console.is_file():
|
||||||
|
print(console.read_text(encoding="utf-8", errors="replace"))
|
||||||
|
run.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compiler_build(suite_run: SuiteRun) -> None:
|
||||||
|
assert suite_run.result() == "PASS", suite_run.log()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("case", CASES, ids=lambda case: case.id)
|
||||||
|
def test_milestone_case(case: Case, suite_run: SuiteRun) -> None:
|
||||||
|
if suite_run.result() != "PASS":
|
||||||
|
pytest.skip("compiler build failed")
|
||||||
|
assert suite_run.result(case) == "PASS", (
|
||||||
|
f"DOS command: {case.command}\nExpected success: {case.expect_success}\n"
|
||||||
|
f"{suite_run.log(case)}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"dosboxx": {
|
||||||
|
"version": "2026.08.02",
|
||||||
|
"url": "https://github.com/joncampbell123/dosbox-x/releases/download/dosbox-x-v2026.08.02/dosbox-x-vsbuild-win64-2026.08.02-portable.zip",
|
||||||
|
"sha256": "ca28208f5fee25a74caf3a02cc0189c7f7943a42ce37c02848f5fc03450a96cf",
|
||||||
|
"executable": "bin/x64/Release/dosbox-x.exe"
|
||||||
|
},
|
||||||
|
"open_watcom": {
|
||||||
|
"version": "2026-08-01-Build",
|
||||||
|
"url": "https://github.com/open-watcom/open-watcom-v2/releases/download/2026-08-01-Build/open-watcom-2_0-c-dos.exe",
|
||||||
|
"sha256": "80db4ab340f382e59bf3d396280576ec837964c2ef00e8ddd3b2b3724ab63edf",
|
||||||
|
"required": [
|
||||||
|
"binw/wcl.exe",
|
||||||
|
"binw/wcl386.exe",
|
||||||
|
"binp/wlink.exe",
|
||||||
|
"h/stdio.h",
|
||||||
|
"lib286/dos/clibl.lib",
|
||||||
|
"lib386/dos/clib3r.lib",
|
||||||
|
"license.txt"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -175,12 +175,14 @@ version = "0.1.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "onnxruntime" },
|
{ name = "onnxruntime" },
|
||||||
|
{ name = "pytest" },
|
||||||
{ name = "rapidocr" },
|
{ name = "rapidocr" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "onnxruntime", specifier = ">=1.28.0" },
|
{ name = "onnxruntime", specifier = ">=1.28.0" },
|
||||||
|
{ name = "pytest", specifier = ">=9.0.0" },
|
||||||
{ name = "rapidocr", specifier = ">=3.9.2" },
|
{ name = "rapidocr", specifier = ">=3.9.2" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -201,6 +203,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 },
|
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numpy"
|
name = "numpy"
|
||||||
version = "2.5.2"
|
version = "2.5.2"
|
||||||
@@ -418,6 +429,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491 },
|
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "protobuf"
|
name = "protobuf"
|
||||||
version = "7.35.1"
|
version = "7.35.1"
|
||||||
@@ -463,6 +483,31 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/8f/dc/53df8b6931d47080b4fe4ee8450d42e660ee1c5c1556c7ab73359182b769/pyclipper-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04", size = 117608 },
|
{ url = "https://files.pythonhosted.org/packages/8f/dc/53df8b6931d47080b4fe4ee8450d42e660ee1c5c1556c7ab73359182b769/pyclipper-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04", size = 117608 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user