From 12bff76e1d33ef1dbc94563c05a2001e2e2e3eb4 Mon Sep 17 00:00:00 2001 From: AmuroEita <1071307515@qq.com> Date: Tue, 4 Aug 2026 16:37:38 +0800 Subject: [PATCH 1/5] feat: bench for tutorials --- .github/workflows/ci.yml | 3 +- CONTRIBUTING.md | 2 +- go.mod | 3 +- go.sum | 4 +- kvspace.go | 55 ----- tutorial/01-basics/arith.c | 22 ++ tutorial/01-basics/arith.py | 19 ++ tutorial/01-basics/hello.c | 6 + tutorial/01-basics/hello.py | 1 + tutorial/01-basics/time.kv | 12 +- tutorial/04-algo/factorial.c | 15 ++ tutorial/04-algo/factorial.kv | 2 - tutorial/04-algo/factorial.py | 8 + tutorial/04-algo/fibonacci.c | 21 ++ tutorial/04-algo/fibonacci.kv | 9 +- tutorial/04-algo/fibonacci.py | 10 + tutorial/04-algo/gcd.c | 15 ++ tutorial/04-algo/gcd.kv | 3 +- tutorial/04-algo/gcd.py | 7 + tutorial/04-algo/prime_sieve.c | 27 +++ tutorial/04-algo/prime_sieve.kv | 3 +- tutorial/04-algo/prime_sieve.py | 16 ++ tutorial/benchmark.csv | 7 + tutorial/test.py | 381 +++++++++++++++++++++++++++++++- tutorial/tutorial.sh | 25 ++- 25 files changed, 584 insertions(+), 92 deletions(-) delete mode 100644 kvspace.go create mode 100644 tutorial/01-basics/arith.c create mode 100644 tutorial/01-basics/arith.py create mode 100644 tutorial/01-basics/hello.c create mode 100644 tutorial/01-basics/hello.py create mode 100644 tutorial/04-algo/factorial.c create mode 100644 tutorial/04-algo/factorial.py create mode 100644 tutorial/04-algo/fibonacci.c create mode 100644 tutorial/04-algo/fibonacci.py create mode 100644 tutorial/04-algo/gcd.c create mode 100644 tutorial/04-algo/gcd.py create mode 100644 tutorial/04-algo/prime_sieve.c create mode 100644 tutorial/04-algo/prime_sieve.py create mode 100644 tutorial/benchmark.csv diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 843bc69..1ea9046 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,8 @@ 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 tutorial/test.py + - run: python3 -m unittest tutorial.test.BenchmarkTest + - run: KVLANG_TEST_REDIS=1 python3 tutorial/test.py # ── 多平台交叉编译(仅 tag 时生成 release 产物)───────────────────────── release: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5a4097..81da9b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ make build # After making changes: make vet # static analysis make test # unit tests -python3 run.py # integration tests (requires Redis) +KVLANG_TEST_REDIS=1 python3 tutorial/test.py # requires a dedicated local Redis ``` ## Project structure diff --git a/go.mod b/go.mod index 50473d1..11b2015 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module kvlang go 1.24.4 require ( - github.com/array2d/kvspace-go v0.1.0 + github.com/array2d/kvspace-go v0.1.1-0.20260803065547-95eed23cf74f github.com/gorilla/websocket v1.5.3 ) @@ -12,4 +12,3 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/redis/go-redis/v9 v9.7.0 // indirect ) - diff --git a/go.sum b/go.sum index 0e2e7ea..48c97af 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/array2d/kvspace-go v0.1.0 h1:/Kt06T4MXVTKUXREcLe/7Vii5/+0aULQsJ2cdbU310M= -github.com/array2d/kvspace-go v0.1.0/go.mod h1:FtalN1LxRD9NYQ6V4ga90P+6tAYjOgtjnI1hq/D0x8I= +github.com/array2d/kvspace-go v0.1.1-0.20260803065547-95eed23cf74f h1:2hDGZpEPjkynt6d+R/tsEqM5RBXhJiIXoVgRFrCRdaY= +github.com/array2d/kvspace-go v0.1.1-0.20260803065547-95eed23cf74f/go.mod h1:dAQu8esUeKAMIkj6nD0BW+nRJBPmIpzlipbLEDJeLb4= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 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/01-basics/time.kv b/tutorial/01-basics/time.kv index 64040c8..66c33da 100644 --- a/tutorial/01-basics/time.kv +++ b/tutorial/01-basics/time.kv @@ -5,8 +5,8 @@ # t0 < t1: true # t0 > t1: false # delta >= 0 ns: true -# delta ms = 0 -# delta s = 0 +# delta ms >= 0: true +# delta s >= 0: true # t0 + delta == t1: true # 1000 ms # 1 s = 1000000000 ns @@ -30,12 +30,12 @@ rwfunc main() -> () { println(time.duration.before(zero, delta)) ms <- time.duration.as_millis(delta) - print("delta ms = ") - println(ms) + print("delta ms >= 0: ") + println(ms >= 0) s <- time.duration.as_seconds(delta) - print("delta s = ") - println(s) + print("delta s >= 0: ") + println(s >= 0) sum <- time.add(t0, delta) print("t0 + delta == t1: ") 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..f077c6c 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,135 @@ def parse_expects(f: Path) -> list[str]: return pats +def _flush_redis() -> None: + dsn = os.environ.get("KVLANG_KVSPACE", "redis://127.0.0.1:6379") + if "://" not in dsn: + dsn = f"redis://{dsn}" + if not dsn.startswith("redis://"): + return + if os.environ.get("KVLANG_TEST_REDIS") != "1": + raise RuntimeError( + "refusing to clear Redis without KVLANG_TEST_REDIS=1; " + "point KVLANG_KVSPACE at a dedicated test instance", + ) + try: + result = subprocess.run( + ["redis-cli", "-u", dsn, "FLUSHDB"], + capture_output=True, + text=True, + timeout=5, + ) + except FileNotFoundError as exc: + raise RuntimeError("redis-cli is required for Redis test isolation") from exc + if result.returncode != 0 or result.stdout.strip() != "OK": + detail = result.stderr.strip() or result.stdout.strip() or "unknown error" + raise RuntimeError(f"failed to clear dedicated Redis test database: {detail}") + + +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 +188,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 +266,235 @@ 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.dict( + os.environ, + {"KVLANG_KVSPACE": "art://local"}, + ), + 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_redis_flush_requires_dedicated_opt_in(self): + with ( + mock.patch.dict( + os.environ, + {"KVLANG_KVSPACE": "redis://127.0.0.1:6380"}, + clear=True, + ), + mock.patch.object(subprocess, "run") as run, + self.assertRaisesRegex(RuntimeError, "dedicated test instance"), + ): + _flush_redis() + + run.assert_not_called() + + def test_redis_flush_rejects_server_error(self): + failed = subprocess.CompletedProcess( + [], 0, stdout="NOAUTH Authentication required.\n", stderr="", + ) + with ( + mock.patch.dict( + os.environ, + { + "KVLANG_KVSPACE": "redis://127.0.0.1:6380", + "KVLANG_TEST_REDIS": "1", + }, + clear=True, + ), + mock.patch.object(subprocess, "run", return_value=failed), + self.assertRaisesRegex(RuntimeError, "failed to clear"), + ): + _flush_redis() + + 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() + + def test_regular_run_flushes_redis_then_invokes_kvlang(self): + source = self.write_fixture() + redis_result = subprocess.CompletedProcess([], 0, stdout="OK\n", stderr="") + kvlang_result = subprocess.CompletedProcess( + [], 0, stdout="answer 42\n", stderr="", + ) + + with ( + mock.patch.object(MODULE, "discover", return_value=[source]), + mock.patch.object( + subprocess, "run", side_effect=[redis_result, kvlang_result], + ) as run, + mock.patch.dict( + os.environ, + { + "KVLANG_KVSPACE": "redis://127.0.0.1:6380", + "KVLANG_TEST_REDIS": "1", + }, + ), + mock.patch.object(sys, "argv", ["test.py", "--no-build"]), + self.assertRaises(SystemExit) as exit_context, + ): + main() + + self.assertEqual(exit_context.exception.code, 0) + self.assertEqual( + run.call_args_list, + [ + mock.call( + [ + "redis-cli", + "-u", + "redis://127.0.0.1:6380", + "FLUSHDB", + ], + capture_output=True, + text=True, + timeout=5, + ), + mock.call( + [str(self.kvlang), "tutorial/case.kv"], + capture_output=True, + text=True, + timeout=60, + cwd=str(self.root), + ), + ], + ) + + if __name__ == "__main__": main() diff --git a/tutorial/tutorial.sh b/tutorial/tutorial.sh index 83538cf..33b8e9d 100755 --- a/tutorial/tutorial.sh +++ b/tutorial/tutorial.sh @@ -105,16 +105,25 @@ GOPROXY="$GOPROXY" PREFIX="$INSTALL_PREFIX" make build export PATH="$INSTALL_PREFIX/bin:$PATH" # ═══════════════════════════════════════════════ -# step 4: start redis +# step 4: start a dedicated test redis # ═══════════════════════════════════════════════ -if ! redis-cli ping &>/dev/null; then - say "starting redis-server" - redis-server --daemonize yes --loglevel warning - sleep 1 +KVLANG_TEST_REDIS_PORT="${KVLANG_TEST_REDIS_PORT:-16379}" +if redis-cli -p "$KVLANG_TEST_REDIS_PORT" ping &>/dev/null; then + die "port $KVLANG_TEST_REDIS_PORT is already running Redis; choose a dedicated port with KVLANG_TEST_REDIS_PORT" fi -redis-cli ping &>/dev/null || die "redis-server failed to start" -say "redis is up" +say "starting dedicated redis-server on port $KVLANG_TEST_REDIS_PORT" +redis-server \ + --bind 127.0.0.1 \ + --port "$KVLANG_TEST_REDIS_PORT" \ + --save '' \ + --appendonly no \ + --daemonize yes \ + --loglevel warning +trap 'redis-cli -p "$KVLANG_TEST_REDIS_PORT" shutdown nosave &>/dev/null || true' EXIT +sleep 1 +redis-cli -p "$KVLANG_TEST_REDIS_PORT" ping &>/dev/null || die "dedicated redis-server failed to start" +say "dedicated redis is up" # ═══════════════════════════════════════════════ # step 5: run tutorial tests @@ -122,4 +131,6 @@ say "redis is up" say "running tutorial/test.py" cd "$WORKDIR/kvlang" +KVLANG_KVSPACE="redis://127.0.0.1:$KVLANG_TEST_REDIS_PORT" \ +KVLANG_TEST_REDIS=1 \ python3 tutorial/test.py From cbc2417453136197584f78bdc17496de678d387f Mon Sep 17 00:00:00 2001 From: AmuroEita <1071307515@qq.com> Date: Tue, 4 Aug 2026 19:24:35 +0800 Subject: [PATCH 2/5] chore: restore kvspace-go v0.1.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 11b2015..ab39864 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module kvlang go 1.24.4 require ( - github.com/array2d/kvspace-go v0.1.1-0.20260803065547-95eed23cf74f + github.com/array2d/kvspace-go v0.1.0 github.com/gorilla/websocket v1.5.3 ) diff --git a/go.sum b/go.sum index 48c97af..0e2e7ea 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/array2d/kvspace-go v0.1.1-0.20260803065547-95eed23cf74f h1:2hDGZpEPjkynt6d+R/tsEqM5RBXhJiIXoVgRFrCRdaY= -github.com/array2d/kvspace-go v0.1.1-0.20260803065547-95eed23cf74f/go.mod h1:dAQu8esUeKAMIkj6nD0BW+nRJBPmIpzlipbLEDJeLb4= +github.com/array2d/kvspace-go v0.1.0 h1:/Kt06T4MXVTKUXREcLe/7Vii5/+0aULQsJ2cdbU310M= +github.com/array2d/kvspace-go v0.1.0/go.mod h1:FtalN1LxRD9NYQ6V4ga90P+6tAYjOgtjnI1hq/D0x8I= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= From e480a1182224110666acf029f8f2ed557253b964 Mon Sep 17 00:00:00 2001 From: AmuroEita <1071307515@qq.com> Date: Tue, 4 Aug 2026 19:26:52 +0800 Subject: [PATCH 3/5] chore: restore go.mod formatting --- go.mod | 1 + 1 file changed, 1 insertion(+) diff --git a/go.mod b/go.mod index ab39864..50473d1 100644 --- a/go.mod +++ b/go.mod @@ -12,3 +12,4 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/redis/go-redis/v9 v9.7.0 // indirect ) + From 416366e3d2fc914b924544c3b3958e6ff4c412ea Mon Sep 17 00:00:00 2001 From: AmuroEita <1071307515@qq.com> Date: Tue, 4 Aug 2026 20:01:40 +0800 Subject: [PATCH 4/5] fix: keep benchmark PR focused --- .github/workflows/ci.yml | 3 +- CONTRIBUTING.md | 2 +- tutorial/01-basics/time.kv | 12 ++-- tutorial/test.py | 112 ++----------------------------------- tutorial/tutorial.sh | 25 +++------ 5 files changed, 22 insertions(+), 132 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ea9046..0790ce2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,8 @@ jobs: - 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: KVLANG_TEST_REDIS=1 python3 tutorial/test.py + - run: python3 tutorial/test.py --bench --no-build --filter 01-basics/hello.kv + - run: python3 tutorial/test.py # ── 多平台交叉编译(仅 tag 时生成 release 产物)───────────────────────── release: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 81da9b2..f5a4097 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ make build # After making changes: make vet # static analysis make test # unit tests -KVLANG_TEST_REDIS=1 python3 tutorial/test.py # requires a dedicated local Redis +python3 run.py # integration tests (requires Redis) ``` ## Project structure diff --git a/tutorial/01-basics/time.kv b/tutorial/01-basics/time.kv index 66c33da..64040c8 100644 --- a/tutorial/01-basics/time.kv +++ b/tutorial/01-basics/time.kv @@ -5,8 +5,8 @@ # t0 < t1: true # t0 > t1: false # delta >= 0 ns: true -# delta ms >= 0: true -# delta s >= 0: true +# delta ms = 0 +# delta s = 0 # t0 + delta == t1: true # 1000 ms # 1 s = 1000000000 ns @@ -30,12 +30,12 @@ rwfunc main() -> () { println(time.duration.before(zero, delta)) ms <- time.duration.as_millis(delta) - print("delta ms >= 0: ") - println(ms >= 0) + print("delta ms = ") + println(ms) s <- time.duration.as_seconds(delta) - print("delta s >= 0: ") - println(s >= 0) + print("delta s = ") + println(s) sum <- time.add(t0, delta) print("t0 + delta == t1: ") diff --git a/tutorial/test.py b/tutorial/test.py index f077c6c..5be4bb9 100755 --- a/tutorial/test.py +++ b/tutorial/test.py @@ -50,28 +50,13 @@ def parse_expects(f: Path) -> list[str]: def _flush_redis() -> None: - dsn = os.environ.get("KVLANG_KVSPACE", "redis://127.0.0.1:6379") - if "://" not in dsn: - dsn = f"redis://{dsn}" - if not dsn.startswith("redis://"): - return - if os.environ.get("KVLANG_TEST_REDIS") != "1": - raise RuntimeError( - "refusing to clear Redis without KVLANG_TEST_REDIS=1; " - "point KVLANG_KVSPACE at a dedicated test instance", - ) try: - result = subprocess.run( - ["redis-cli", "-u", dsn, "FLUSHDB"], - capture_output=True, - text=True, - timeout=5, + subprocess.run( + ["redis-cli", "-p", "6379", "FLUSHALL"], + capture_output=True, timeout=5, ) - except FileNotFoundError as exc: - raise RuntimeError("redis-cli is required for Redis test isolation") from exc - if result.returncode != 0 or result.stdout.strip() != "OK": - detail = result.stderr.strip() or result.stdout.strip() or "unknown error" - raise RuntimeError(f"failed to clear dedicated Redis test database: {detail}") + except FileNotFoundError: + pass def _timed_run(command: list[str]) -> tuple[subprocess.CompletedProcess[str], float]: @@ -291,10 +276,7 @@ def setUp(self): 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.dict( - os.environ, - {"KVLANG_KVSPACE": "art://local"}, - ), + mock.patch.object(MODULE, "_flush_redis"), mock.patch("builtins.print"), ] for patcher in self.patchers: @@ -396,38 +378,6 @@ def test_stderr_does_not_invalidate_successful_run(self): "", ) - def test_redis_flush_requires_dedicated_opt_in(self): - with ( - mock.patch.dict( - os.environ, - {"KVLANG_KVSPACE": "redis://127.0.0.1:6380"}, - clear=True, - ), - mock.patch.object(subprocess, "run") as run, - self.assertRaisesRegex(RuntimeError, "dedicated test instance"), - ): - _flush_redis() - - run.assert_not_called() - - def test_redis_flush_rejects_server_error(self): - failed = subprocess.CompletedProcess( - [], 0, stdout="NOAUTH Authentication required.\n", stderr="", - ) - with ( - mock.patch.dict( - os.environ, - { - "KVLANG_KVSPACE": "redis://127.0.0.1:6380", - "KVLANG_TEST_REDIS": "1", - }, - clear=True, - ), - mock.patch.object(subprocess, "run", return_value=failed), - self.assertRaisesRegex(RuntimeError, "failed to clear"), - ): - _flush_redis() - def test_bench_honors_filter_and_no_build(self): keep = self.tutorial / "keep.kv" drop = self.tutorial / "drop.kv" @@ -446,55 +396,5 @@ def test_bench_honors_filter_and_no_build(self): bench.assert_called_once_with([keep], False) run.assert_not_called() - def test_regular_run_flushes_redis_then_invokes_kvlang(self): - source = self.write_fixture() - redis_result = subprocess.CompletedProcess([], 0, stdout="OK\n", stderr="") - kvlang_result = subprocess.CompletedProcess( - [], 0, stdout="answer 42\n", stderr="", - ) - - with ( - mock.patch.object(MODULE, "discover", return_value=[source]), - mock.patch.object( - subprocess, "run", side_effect=[redis_result, kvlang_result], - ) as run, - mock.patch.dict( - os.environ, - { - "KVLANG_KVSPACE": "redis://127.0.0.1:6380", - "KVLANG_TEST_REDIS": "1", - }, - ), - mock.patch.object(sys, "argv", ["test.py", "--no-build"]), - self.assertRaises(SystemExit) as exit_context, - ): - main() - - self.assertEqual(exit_context.exception.code, 0) - self.assertEqual( - run.call_args_list, - [ - mock.call( - [ - "redis-cli", - "-u", - "redis://127.0.0.1:6380", - "FLUSHDB", - ], - capture_output=True, - text=True, - timeout=5, - ), - mock.call( - [str(self.kvlang), "tutorial/case.kv"], - capture_output=True, - text=True, - timeout=60, - cwd=str(self.root), - ), - ], - ) - - if __name__ == "__main__": main() diff --git a/tutorial/tutorial.sh b/tutorial/tutorial.sh index 33b8e9d..83538cf 100755 --- a/tutorial/tutorial.sh +++ b/tutorial/tutorial.sh @@ -105,25 +105,16 @@ GOPROXY="$GOPROXY" PREFIX="$INSTALL_PREFIX" make build export PATH="$INSTALL_PREFIX/bin:$PATH" # ═══════════════════════════════════════════════ -# step 4: start a dedicated test redis +# step 4: start redis # ═══════════════════════════════════════════════ -KVLANG_TEST_REDIS_PORT="${KVLANG_TEST_REDIS_PORT:-16379}" -if redis-cli -p "$KVLANG_TEST_REDIS_PORT" ping &>/dev/null; then - die "port $KVLANG_TEST_REDIS_PORT is already running Redis; choose a dedicated port with KVLANG_TEST_REDIS_PORT" +if ! redis-cli ping &>/dev/null; then + say "starting redis-server" + redis-server --daemonize yes --loglevel warning + sleep 1 fi -say "starting dedicated redis-server on port $KVLANG_TEST_REDIS_PORT" -redis-server \ - --bind 127.0.0.1 \ - --port "$KVLANG_TEST_REDIS_PORT" \ - --save '' \ - --appendonly no \ - --daemonize yes \ - --loglevel warning -trap 'redis-cli -p "$KVLANG_TEST_REDIS_PORT" shutdown nosave &>/dev/null || true' EXIT -sleep 1 -redis-cli -p "$KVLANG_TEST_REDIS_PORT" ping &>/dev/null || die "dedicated redis-server failed to start" -say "dedicated redis is up" +redis-cli ping &>/dev/null || die "redis-server failed to start" +say "redis is up" # ═══════════════════════════════════════════════ # step 5: run tutorial tests @@ -131,6 +122,4 @@ say "dedicated redis is up" say "running tutorial/test.py" cd "$WORKDIR/kvlang" -KVLANG_KVSPACE="redis://127.0.0.1:$KVLANG_TEST_REDIS_PORT" \ -KVLANG_TEST_REDIS=1 \ python3 tutorial/test.py From c442ea2d19e967981bf490bcceeeda972dc8ba14 Mon Sep 17 00:00:00 2001 From: AmuroEita <1071307515@qq.com> Date: Tue, 4 Aug 2026 21:39:02 +0800 Subject: [PATCH 5/5] ci: remove benchmark smoke test --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0790ce2..bb81c9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,6 @@ jobs: - 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 --bench --no-build --filter 01-basics/hello.kv - run: python3 tutorial/test.py # ── 多平台交叉编译(仅 tag 时生成 release 产物)─────────────────────────