diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 843bc69..bb81c9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,7 @@ jobs: - run: go mod tidy - run: go build -o kvlang ./cmd/kvlang/ - run: go install github.com/array2d/kvspace-go/cmd/kvspace@latest + - run: python3 -m unittest tutorial.test.BenchmarkTest - run: python3 tutorial/test.py # ── 多平台交叉编译(仅 tag 时生成 release 产物)───────────────────────── diff --git a/kvspace.go b/kvspace.go deleted file mode 100644 index cdf98f7..0000000 --- a/kvspace.go +++ /dev/null @@ -1,55 +0,0 @@ -// Package kvspace 抽象 KV 存储。 -package kvspace - -import "time" - -// KVPair 用于批量写入,顺序确定(非 map)。 -type KVPair struct { - Key string - Val XValue -} - -// KVSpace KV 存储接口。 -// -// 使用模式: -// -// kv.Set("/vt/0/pc", kvspace.Str("init/[0,0]")) -// v := kv.Get("/vt/0", []string{"pc"})[0]; pc := v.Str() -// kv.Notify("/vt/0/status", kvspace.Str("running")) -// val, _ := kv.Watch("/vt/0/status", 5*time.Second) -// -// Watch/Notify 语义:监听单个 key 的值变化通知,不是通用消息队列。 -// -// Notify(key, val) 向等待者投递 val;不等价于 Set(不写持久值)。 -// Watch(key, timeout) 阻塞等待下一次 Notify;超时返回 (Value{}, ErrNotFound)。 -// -// 软链接透明穿透:Link(target, linkpath) 后,访问 linkpath/x 透明地访问 target/x。 -// 删除语义例外(POSIX rm 式):Del/DelTree/Unlink 的最终组件作用于链接本体, -// 不穿透 target;路径中的祖先链接仍穿透(Del("/alias/x") 删 /real/x)。 -type KVSpace interface { - // ── 单点读写 ───────────────────────────────────────────────────────── - Get(prefix string, keys []string) []XValue //所有key共享prefix,key不含/;缺失返回xvalue(kind=null) - Set(pairs []KVPair) error // 写入并维护目录索引,pre路径如果不存在,则汇报异常 - - // ── 目录操作 ───────────────────────────────────────────────────────── - List(prefix string, expandExt bool) []string // 列出直接子项名;expandExt 合并 extindex 子项 - Del(keys ...string) error // 精确删除(含索引清理) - DelTree(prefix string) error // 递归删除;prefix 本身是链接则只删链接 - - // ── 变更通知 ───────────────────────────────────────────────────────── - Notify(key string, val XValue) error // 投递一次性通知信号 - Watch(key string, timeout time.Duration) XValue // 阻塞等待通知 - - // ── 目录创建 ───────────────────────────────────────────────────────── - Mkindex(path string) error // 递归创建目录,类似 mkdir -p;path 须以 / 结尾 - - // ── mount系统 ─────────────────────────────────────────────────────────── - Link(target, linkpath string) error // 创建路径映射 linkpath → target,纯链接 - ExtIndex(path, extpath string) error // 创建扩展索引,path 为写层,extpath 为只读扩展 - UnLink(path string) error // 移除 extindex - - // ── 生命周期 ───────────────────────────────────────────────────────── - // 范围警示:redis 实现 = FLUSHDB,清空所在 db 的全部键——共享 Redis 实例时会波及非 kvlang 数据。 - Clear() error - DisConn() error -} diff --git a/tutorial/01-basics/arith.c b/tutorial/01-basics/arith.c new file mode 100644 index 0000000..eddac53 --- /dev/null +++ b/tutorial/01-basics/arith.c @@ -0,0 +1,22 @@ +#include +#include + +int main(void) { + volatile int lhs = 10; + volatile int rhs = 3; + volatile double base = 2.0; + volatile double exponent = 5.0; + volatile double radicand = 144.0; + + printf("add: %d\n", lhs + rhs); + printf("sub: %d\n", lhs - rhs); + printf("mul: %d\n", lhs * rhs); + printf("mul(×): %d\n", lhs * rhs); + printf("div: %d\n", lhs / rhs); + printf("div(÷): %d\n", lhs / rhs); + printf("mod: %d\n", lhs % rhs); + printf("pow: %.1f\n", pow(base, exponent)); + printf("sqrt: %.1f\n", sqrt(radicand)); + printf("sqrt(√): %.1f\n", sqrt(radicand)); + return 0; +} diff --git a/tutorial/01-basics/arith.py b/tutorial/01-basics/arith.py new file mode 100644 index 0000000..1603a1d --- /dev/null +++ b/tutorial/01-basics/arith.py @@ -0,0 +1,19 @@ +import math + + +lhs = 10 +rhs = 3 +base = 2.0 +exponent = 5.0 +radicand = 144.0 + +print("add:", lhs + rhs) +print("sub:", lhs - rhs) +print("mul:", lhs * rhs) +print("mul(×):", lhs * rhs) +print("div:", lhs // rhs) +print("div(÷):", lhs // rhs) +print("mod:", lhs % rhs) +print("pow:", base**exponent) +print("sqrt:", math.sqrt(radicand)) +print("sqrt(√):", math.sqrt(radicand)) diff --git a/tutorial/01-basics/hello.c b/tutorial/01-basics/hello.c new file mode 100644 index 0000000..d11a6a0 --- /dev/null +++ b/tutorial/01-basics/hello.c @@ -0,0 +1,6 @@ +#include + +int main(void) { + puts("hello kvlang"); + return 0; +} diff --git a/tutorial/01-basics/hello.py b/tutorial/01-basics/hello.py new file mode 100644 index 0000000..46a2df1 --- /dev/null +++ b/tutorial/01-basics/hello.py @@ -0,0 +1 @@ +print("hello kvlang") diff --git a/tutorial/04-algo/factorial.c b/tutorial/04-algo/factorial.c new file mode 100644 index 0000000..400ac65 --- /dev/null +++ b/tutorial/04-algo/factorial.c @@ -0,0 +1,15 @@ +#include + +static long long factorial(int n) { + long long result = 1; + for (int i = 1; i <= n; ++i) { + result *= i; + } + return result; +} + +int main(void) { + volatile int n = 10; + printf("fact = %lld\n", factorial(n)); + return 0; +} diff --git a/tutorial/04-algo/factorial.kv b/tutorial/04-algo/factorial.kv index ba46a7e..bf2644f 100644 --- a/tutorial/04-algo/factorial.kv +++ b/tutorial/04-algo/factorial.kv @@ -7,11 +7,9 @@ rwfunc factorial(n:int64) -> (result:int64) { i <- 1 while (i <= n) { result = result * i # = 等价于 <- - result_m = result × i # = 等价于 <- i + 1 -> i } } ans <- factorial(10) println("fact =", ans) -/last_fact = ans # = 等价于 <- diff --git a/tutorial/04-algo/factorial.py b/tutorial/04-algo/factorial.py new file mode 100644 index 0000000..4691ef6 --- /dev/null +++ b/tutorial/04-algo/factorial.py @@ -0,0 +1,8 @@ +def factorial(n: int) -> int: + result = 1 + for i in range(1, n + 1): + result *= i + return result + + +print("fact =", factorial(10)) diff --git a/tutorial/04-algo/fibonacci.c b/tutorial/04-algo/fibonacci.c new file mode 100644 index 0000000..1813564 --- /dev/null +++ b/tutorial/04-algo/fibonacci.c @@ -0,0 +1,21 @@ +#include + +static long long fibonacci(int n) { + if (n <= 1) { + return n; + } + long long a = 0; + long long b = 1; + for (int i = 2; i <= n; ++i) { + long long next = a + b; + a = b; + b = next; + } + return b; +} + +int main(void) { + volatile int n = 10; + printf("fib = %lld\n", fibonacci(n)); + return 0; +} diff --git a/tutorial/04-algo/fibonacci.kv b/tutorial/04-algo/fibonacci.kv index 77a83af..d1db90e 100644 --- a/tutorial/04-algo/fibonacci.kv +++ b/tutorial/04-algo/fibonacci.kv @@ -4,21 +4,20 @@ # fib = 55 rwfunc fibonacci(n:int64) -> (result:int64) { if (n <= 1) { - n + 0 -> result + n -> result } else { a <- 0 b = 1 # = 等价于 <- 2 -> i while (i <= n) { c <- a + b - a = b + 0 # = 等价于 <- - c + 0 -> b + a = b # = 等价于 <- + c -> b i <- i + 1 } - result = b + 0 # = 等价于 <- + result = b # = 等价于 <- } } fibonacci(10) -> ans println("fib =", ans) -/last_fib <- ans diff --git a/tutorial/04-algo/fibonacci.py b/tutorial/04-algo/fibonacci.py new file mode 100644 index 0000000..af6736f --- /dev/null +++ b/tutorial/04-algo/fibonacci.py @@ -0,0 +1,10 @@ +def fibonacci(n: int) -> int: + if n <= 1: + return n + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + return b + + +print("fib =", fibonacci(10)) diff --git a/tutorial/04-algo/gcd.c b/tutorial/04-algo/gcd.c new file mode 100644 index 0000000..a1d68f2 --- /dev/null +++ b/tutorial/04-algo/gcd.c @@ -0,0 +1,15 @@ +#include + +static long long gcd(long long a, long long b) { + if (b == 0) { + return a; + } + return gcd(b, a % b); +} + +int main(void) { + volatile long long a = 48; + volatile long long b = 18; + printf("gcd = %lld\n", gcd(a, b)); + return 0; +} diff --git a/tutorial/04-algo/gcd.kv b/tutorial/04-algo/gcd.kv index 6ec2070..7af144f 100644 --- a/tutorial/04-algo/gcd.kv +++ b/tutorial/04-algo/gcd.kv @@ -4,7 +4,7 @@ # gcd = 6 rwfunc gcd(A:int64, B:int64) -> (R:int64) { if (B == 0) { - A + 0 -> R + A -> R } else { rem <- A % B R = gcd(B, rem) # = 等价于 <- @@ -13,4 +13,3 @@ rwfunc gcd(A:int64, B:int64) -> (R:int64) { gcd(48, 18) -> ans println("gcd =", ans) -/last_gcd <- ans diff --git a/tutorial/04-algo/gcd.py b/tutorial/04-algo/gcd.py new file mode 100644 index 0000000..c61ca4e --- /dev/null +++ b/tutorial/04-algo/gcd.py @@ -0,0 +1,7 @@ +def gcd(a: int, b: int) -> int: + if b == 0: + return a + return gcd(b, a % b) + + +print("gcd =", gcd(48, 18)) diff --git a/tutorial/04-algo/prime_sieve.c b/tutorial/04-algo/prime_sieve.c new file mode 100644 index 0000000..9da72a7 --- /dev/null +++ b/tutorial/04-algo/prime_sieve.c @@ -0,0 +1,27 @@ +#include +#include + +static void prime_sieve(int limit) { + printf("primes up to %d\n", limit); + int count = 0; + for (int n = 2; n <= limit; ++n) { + bool is_prime = true; + for (int divisor = 2; divisor < n; ++divisor) { + if (n % divisor == 0) { + is_prime = false; + break; + } + } + if (is_prime) { + printf(" prime: %d\n", n); + ++count; + } + } + printf("total primes up to %d = %d\n", limit, count); +} + +int main(void) { + volatile int limit = 30; + prime_sieve(limit); + return 0; +} diff --git a/tutorial/04-algo/prime_sieve.kv b/tutorial/04-algo/prime_sieve.kv index 7d84202..9e34ffa 100644 --- a/tutorial/04-algo/prime_sieve.kv +++ b/tutorial/04-algo/prime_sieve.kv @@ -17,7 +17,7 @@ rwfunc prime_sieve(limit:int64) -> () { if (divisible) { is_prime = false # = 等价于 <- - n -> d + break } else { d <- d + 1 } @@ -31,7 +31,6 @@ rwfunc prime_sieve(limit:int64) -> () { n + 1 -> n } println("total primes up to", limit, "=", count) - /last_primes <- count } prime_sieve(30) diff --git a/tutorial/04-algo/prime_sieve.py b/tutorial/04-algo/prime_sieve.py new file mode 100644 index 0000000..d39325c --- /dev/null +++ b/tutorial/04-algo/prime_sieve.py @@ -0,0 +1,16 @@ +def prime_sieve(limit: int) -> None: + print("primes up to", limit) + count = 0 + for n in range(2, limit + 1): + is_prime = True + for divisor in range(2, n): + if n % divisor == 0: + is_prime = False + break + if is_prime: + print(" prime:", n) + count += 1 + print("total primes up to", limit, "=", count) + + +prime_sieve(30) diff --git a/tutorial/benchmark.csv b/tutorial/benchmark.csv new file mode 100644 index 0000000..b488d79 --- /dev/null +++ b/tutorial/benchmark.csv @@ -0,0 +1,7 @@ +file,kvlang_ms,python_ms,c_ms +tutorial/01-basics/arith.kv,376.663,25.367,5.123 +tutorial/01-basics/hello.kv,86.866,24.346,4.780 +tutorial/04-algo/factorial.kv,917.746,25.138,5.087 +tutorial/04-algo/fibonacci.kv,1225.605,25.423,5.636 +tutorial/04-algo/gcd.kv,729.042,24.436,4.597 +tutorial/04-algo/prime_sieve.kv,22567.890,25.147,5.014 diff --git a/tutorial/test.py b/tutorial/test.py index 19f0174..5be4bb9 100755 --- a/tutorial/test.py +++ b/tutorial/test.py @@ -2,13 +2,26 @@ """kvlang tutorial test — 从 .kv 文件 # 期望输出 头注释自动生成测试。""" from __future__ import annotations -import argparse, csv, os, re, subprocess, sys + +import argparse +import csv +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +import unittest from pathlib import Path +from unittest import mock RED, GREEN, YELLOW, NC = "\033[0;31m", "\033[0;32m", "\033[1;33m", "\033[0m" ROOT = Path(__file__).resolve().parent.parent KV = str(ROOT / "kvlang") FAIL_CSV = (ROOT / "tutorial" / "test_failures.csv").resolve() +BENCH_CSV = (ROOT / "tutorial" / "benchmark.csv").resolve() +MODULE = sys.modules[__name__] def discover(root: Path) -> list[Path]: @@ -36,11 +49,120 @@ def parse_expects(f: Path) -> list[str]: return pats +def _flush_redis() -> None: + try: + subprocess.run( + ["redis-cli", "-p", "6379", "FLUSHALL"], + capture_output=True, timeout=5, + ) + except FileNotFoundError: + pass + + +def _timed_run(command: list[str]) -> tuple[subprocess.CompletedProcess[str], float]: + started = time.perf_counter() + result = subprocess.run(command, capture_output=True, text=True, + timeout=60, cwd=str(ROOT)) + return result, (time.perf_counter() - started) * 1000 + + +def _benchmark_error(results: dict[str, subprocess.CompletedProcess[str]], + expects: list[str]) -> str: + for name, result in results.items(): + if result.returncode != 0: + return f"{name} exited with status {result.returncode}" + outputs = {name: result.stdout for name, result in results.items()} + for name, output in outputs.items(): + if any(expected not in output for expected in expects): + return f"{name} output does not match expected output" + if len(set(outputs.values())) != 1: + return "program outputs differ" + return "" + + +def _invalid_row(f: Path) -> dict[str, str]: + return { + "file": str(f.relative_to(ROOT)), + "kvlang_ms": "invalid", + "python_ms": "invalid", + "c_ms": "invalid", + } + + +def _benchmark_file(f: Path, expects: list[str]) -> tuple[dict[str, str], str]: + rel = str(f.relative_to(ROOT)) + invalid = _invalid_row(f) + with tempfile.TemporaryDirectory(prefix="kvlang-bench-") as tmp: + executable = Path(tmp) / "program" + try: + compiled = subprocess.run( + ["gcc", "-O3", str(f.with_suffix(".c")), "-o", str(executable), "-lm"], + capture_output=True, text=True, timeout=60, cwd=str(ROOT), + ) + except FileNotFoundError: + return invalid, "gcc not found" + except subprocess.TimeoutExpired: + return invalid, "C compilation timed out" + if compiled.returncode != 0: + return invalid, "C compilation failed" + + try: + _flush_redis() + kv_result, kv_ms = _timed_run([KV, rel]) + py_result, py_ms = _timed_run([sys.executable, str(f.with_suffix(".py"))]) + c_result, c_ms = _timed_run([str(executable)]) + except FileNotFoundError as exc: + return invalid, f"command not found: {exc.filename}" + except subprocess.TimeoutExpired: + return invalid, "program timed out" + + error = _benchmark_error( + {"kvlang": kv_result, "python": py_result, "c": c_result}, expects, + ) + if error: + return invalid, error + return { + "file": rel, + "kvlang_ms": f"{kv_ms:.3f}", + "python_ms": f"{py_ms:.3f}", + "c_ms": f"{c_ms:.3f}", + }, "" + + +def run_benchmarks(files: list[Path], errorexit: bool = False) -> int: + rows = [] + skipped = invalid = 0 + for f in files: + if not f.with_suffix(".py").is_file() or not f.with_suffix(".c").is_file(): + skipped += 1 + continue + expects = parse_expects(f) + if expects: + row, error = _benchmark_file(f, expects) + else: + row, error = _invalid_row(f), "missing # 期望输出" + rows.append(row) + rel = str(f.relative_to(ROOT)) + if error: + invalid += 1 + print(f"{RED}❌ bench {rel}: invalid — {error}{NC}") + if errorexit: + break + else: + print(f"{GREEN}✅ bench {rel}{NC}") + + _write_benchmark_csv(rows) + print(f"{YELLOW}══ VALID:{len(rows) - invalid} INVALID:{invalid} SKIP:{skipped} ══{NC}") + print(f"report: {BENCH_CSV}") + return invalid + + def main(): ap = argparse.ArgumentParser(description="tutorial test") ap.add_argument("--filter", default="", help="filter by name") ap.add_argument("--no-build", action="store_true", help="skip make build") ap.add_argument("--errorexit", action="store_true", help="exit on first error") + ap.add_argument("--bench", action="store_true", help="benchmark matching .kv/.py/.c files") args = ap.parse_args() if not args.no_build: @@ -51,27 +173,27 @@ def main(): sys.exit(1) print(f"{GREEN}✅ build ok{NC}") - passed = failed = 0 - failures: list[dict] = [] files = [f for f in discover(ROOT / "tutorial") if args.filter in str(f)] print(f"kvlang: {os.path.abspath(KV)}") + if args.bench: + sys.exit(1 if run_benchmarks(files, args.errorexit) else 0) + if not files: print(f"{YELLOW}no .kv files found{NC}") sys.exit(0) + passed = failed = 0 + failures: list[dict] = [] for f in files: expects = parse_expects(f) if not expects: continue rel = str(f.relative_to(ROOT)) try: - try: - subprocess.run(["redis-cli", "-p", "6379", "FLUSHALL"], capture_output=True, timeout=5) - except FileNotFoundError: - pass + _flush_redis() r = subprocess.run([KV, rel], capture_output=True, text=True, timeout=60, cwd=str(ROOT)) all_ok = True @@ -129,5 +251,150 @@ def _write_csv(failures: list[dict]) -> None: w.writerow(row) +def _write_benchmark_csv(rows: list[dict[str, str]]) -> None: + with open(BENCH_CSV, "w", newline="", encoding="utf-8") as fh: + w = csv.DictWriter( + fh, + fieldnames=["file", "kvlang_ms", "python_ms", "c_ms"], + lineterminator="\n", + ) + w.writeheader() + w.writerows(rows) + + +class BenchmarkTest(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.root = Path(self.tempdir.name) + self.tutorial = self.root / "tutorial" + self.tutorial.mkdir() + self.kvlang = self.root / "kvlang" + self.kvlang.write_text("#!/bin/sh\nprintf 'answer 42\\n'\n", encoding="utf-8") + self.kvlang.chmod(0o755) + self.patchers = [ + mock.patch.object(MODULE, "ROOT", self.root), + mock.patch.object(MODULE, "KV", str(self.kvlang)), + mock.patch.object(MODULE, "FAIL_CSV", self.tutorial / "test_failures.csv"), + mock.patch.object(MODULE, "BENCH_CSV", self.tutorial / "benchmark.csv"), + mock.patch.object(MODULE, "_flush_redis"), + mock.patch("builtins.print"), + ] + for patcher in self.patchers: + patcher.start() + + def tearDown(self): + for patcher in reversed(self.patchers): + patcher.stop() + self.tempdir.cleanup() + + def write_fixture(self, python_output="answer 42", include_c=True): + source = self.tutorial / "case.kv" + source.write_text("# 期望输出:\n# answer 42\n", encoding="utf-8") + source.with_suffix(".py").write_text( + f"print({python_output!r})\n", encoding="utf-8", + ) + if include_c: + source.with_suffix(".c").write_text( + '#include \nint main(void) { puts("answer 42"); return 0; }\n', + encoding="utf-8", + ) + return source + + def read_rows(self): + with open(BENCH_CSV, newline="", encoding="utf-8") as fh: + return list(csv.DictReader(fh)) + + @unittest.skipUnless(shutil.which("gcc"), "gcc is required") + def test_writes_timings_for_matching_outputs(self): + source = self.write_fixture() + + self.assertEqual(run_benchmarks([source]), 0) + + rows = self.read_rows() + self.assertEqual(len(rows), 1) + self.assertEqual( + list(rows[0]), ["file", "kvlang_ms", "python_ms", "c_ms"], + ) + self.assertEqual(rows[0]["file"], "tutorial/case.kv") + self.assertNotIn(b"\r\n", BENCH_CSV.read_bytes()) + for field in ("kvlang_ms", "python_ms", "c_ms"): + self.assertGreaterEqual(float(rows[0][field]), 0) + + @unittest.skipUnless(shutil.which("gcc"), "gcc is required") + def test_marks_output_mismatch_invalid(self): + source = self.write_fixture(python_output="wrong") + + self.assertEqual(run_benchmarks([source]), 1) + + row = self.read_rows()[0] + self.assertEqual( + [row["kvlang_ms"], row["python_ms"], row["c_ms"]], + ["invalid", "invalid", "invalid"], + ) + + @unittest.skipUnless(shutil.which("gcc"), "gcc is required") + def test_marks_different_outputs_invalid(self): + source = self.write_fixture(python_output="answer 42 ") + + self.assertEqual(run_benchmarks([source]), 1) + + row = self.read_rows()[0] + self.assertEqual(row["python_ms"], "invalid") + + def test_skips_missing_counterpart(self): + source = self.write_fixture(include_c=False) + + self.assertEqual(run_benchmarks([source]), 0) + self.assertEqual(self.read_rows(), []) + + def test_marks_missing_expected_output_invalid(self): + source = self.write_fixture() + source.write_text('println("answer 42")\n', encoding="utf-8") + + self.assertEqual(run_benchmarks([source]), 1) + self.assertEqual(self.read_rows()[0]["kvlang_ms"], "invalid") + + def test_marks_nonzero_exit_invalid(self): + failed = subprocess.CompletedProcess( + [], 1, stdout="answer 42\n", stderr="warning\n", + ) + + self.assertIn( + "exited with status 1", + _benchmark_error( + {"kvlang": failed, "python": failed, "c": failed}, ["answer 42"], + ), + ) + + def test_stderr_does_not_invalidate_successful_run(self): + result = subprocess.CompletedProcess( + [], 0, stdout="answer 42\n", stderr="warning\n", + ) + + self.assertEqual( + _benchmark_error( + {"kvlang": result, "python": result, "c": result}, ["answer 42"], + ), + "", + ) + + def test_bench_honors_filter_and_no_build(self): + keep = self.tutorial / "keep.kv" + drop = self.tutorial / "drop.kv" + with ( + mock.patch.object(MODULE, "discover", return_value=[drop, keep]), + mock.patch.object(MODULE, "run_benchmarks", return_value=0) as bench, + mock.patch.object(subprocess, "run") as run, + mock.patch.object( + sys, "argv", ["test.py", "--bench", "--no-build", "--filter", "keep"], + ), + self.assertRaises(SystemExit) as exit_context, + ): + main() + + self.assertEqual(exit_context.exception.code, 0) + bench.assert_called_once_with([keep], False) + run.assert_not_called() + if __name__ == "__main__": main()