diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..93bada6 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,49 @@ +name: docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build-deploy: + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install Mojo nightly + run: | + uv venv + uv pip install mojo \ + --index https://whl.modular.com/nightly/simple/ \ + --prerelease allow + + - name: Generate API reference + run: | + .venv/bin/mojo doc -o docs/api.json -I src src/redis + python3 docs/render_api.py docs/api.json docs/site/index.html + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/site + + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 55b0523..309832c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,11 @@ jobs: --prerelease allow .venv/bin/mojo --version + - name: Format check + run: | + .venv/bin/mojo format src/ test/ examples/ bench/ + git diff --exit-code || (echo "::error::Run 'pixi run fmt' — sources are not mojo-format clean" && exit 1) + # Protocol unit tests only — they need no Redis server. The # integration suite (test/test_integration.mojo) requires a live # server and is run locally, not in CI, for v0.1. diff --git a/.gitignore b/.gitignore index fc1e371..d7fa799 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ pixi.lock *.mojopkg __pycache__/ +.bench_resp +docs/api.json +docs/api.html +docs/site/ diff --git a/bench/bench_resp.mojo b/bench/bench_resp.mojo new file mode 100755 index 0000000..84c72c1 --- /dev/null +++ b/bench/bench_resp.mojo @@ -0,0 +1,89 @@ +"""Offline throughput benchmark for the RESP2 serializer and parser. + +Network-free: serializes commands with `encode_command` and decodes canned +replies with `parse_reply` over in-memory byte buffers — the same pure +protocol path `test/test_resp.mojo` exercises, so no Redis server is +required. Run compiled for meaningful numbers: +`mojo build -I src bench/bench_resp.mojo -o .bench_resp && ./.bench_resp` +(or `pixi run bench`). +""" +from std.time import perf_counter_ns + +from redis.resp import encode_command, parse_reply + + +def _bytes(s: String) -> List[UInt8]: + var out = List[UInt8]() + for b in s.as_bytes(): + out.append(b) + return out^ + + +def _report(name: String, bytes_per_op: Int, iterations: Int, elapsed_ns: UInt): + var ns_per_op = Float64(elapsed_ns) / Float64(iterations) + var mb_per_s = ( + Float64(bytes_per_op) + * Float64(iterations) + / (1024.0 * 1024.0) + / (Float64(elapsed_ns) / 1e9) + ) + print(name) + print(t" {bytes_per_op} bytes/op, {ns_per_op} ns/op, {mb_per_s} MB/s") + + +def bench_encode(name: String, args: List[String], iterations: Int) raises: + # Warmup + correctness anchor: the encoded size must be stable. + var warm = encode_command(args) + var size = len(warm) + var start = perf_counter_ns() + for _ in range(iterations): + var buf = encode_command(args) + if len(buf) != size: + raise Error("inconsistent encode") + var elapsed_ns = perf_counter_ns() - start + _report(name, size, iterations, elapsed_ns) + + +def bench_parse(name: String, payload: String, iterations: Int) raises: + var data = _bytes(payload) + # Warmup + correctness anchor: the reply must decode completely. + var warm = parse_reply(Span(data)) + if not warm.ok or warm.consumed != len(data): + raise Error("canned reply did not decode as one complete reply") + var start = perf_counter_ns() + for _ in range(iterations): + var result = parse_reply(Span(data)) + if result.consumed != len(data): + raise Error("inconsistent parse") + var elapsed_ns = perf_counter_ns() - start + _report(name, len(data), iterations, elapsed_ns) + + +def _array_of_bulk(count: Int, item: String) -> String: + """A RESP2 array of `count` bulk strings (an LRANGE-shaped reply).""" + var out = String("*") + String(count) + "\r\n" + for _ in range(count): + out += "$" + String(item.byte_length()) + "\r\n" + item + "\r\n" + return out^ + + +def main() raises: + bench_encode("encode PING", ["PING"], 200_000) + bench_encode( + "encode SET key value", + ["SET", "session:12345", "value-abcdef"], + 200_000, + ) + var payload = String("x") * 512 + bench_encode("encode SET 512B payload", ["SET", "blob:1", payload], 100_000) + + bench_parse("parse simple string (+OK)", "+OK\r\n", 200_000) + bench_parse("parse integer (:1000)", ":1000\r\n", 200_000) + bench_parse( + "parse 512B bulk string", "$512\r\n" + payload + "\r\n", 100_000 + ) + bench_parse( + "parse 100-element array of bulk strings", + _array_of_bulk(100, "list-item-payload"), + 20_000, + ) diff --git a/docs/render_api.py b/docs/render_api.py new file mode 100644 index 0000000..ee8fdc1 --- /dev/null +++ b/docs/render_api.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Render `mojo doc` JSON into a single self-contained HTML API reference. + +`mojo doc` emits JSON, not HTML; this turns that JSON into a searchable, +theme-aware, dependency-free page. Reusable across the pure-Mojo suite — it +reads the package name/version out of the JSON, nothing is hardcoded. + + mojo doc -o docs/api.json -I src src/ + python3 docs/render_api.py docs/api.json docs/api.html +""" +import html +import json +import os +import sys + + +def esc(s): + return html.escape(s or "") + + +def code(s): + return f'{esc(s)}' + + +def render_overload(o): + sig = o.get("signature", "") + doc = o.get("summary") or o.get("description") or "" + raises = o.get("raisesDoc") or "" + parts = [f'
{esc(sig)}
'] + if doc: + parts.append(f'

{esc(doc)}

') + if raises: + parts.append(f'

raises {esc(raises)}

') + return "".join(parts) + + +def render_function(f): + name = f.get("name", "") + body = "".join(render_overload(o) for o in f.get("overloads", [])) + return (f'
' + f'

{esc(name)}fn

{body}
') + + +def render_field(fl): + name = fl.get("name", "") + typ = fl.get("type", "") or fl.get("signature", "") + doc = fl.get("summary") or fl.get("description") or "" + d = f' — {esc(doc)}' if doc else "" + label = f'{code(name)}: {code(typ)}' if typ else code(name) + return f'
  • {label}{d}
  • ' + + +def render_struct(s): + name = s.get("name", "") + summary = s.get("summary") or s.get("description") or "" + traits = s.get("parentTraits") or [] + fields = s.get("fields", []) + methods = s.get("functions", []) + out = [f'
    '] + out.append(f'

    {esc(name)}struct

    ') + if traits: + names = [t.get("name", "") if isinstance(t, dict) else str(t) for t in traits] + out.append('

    ' + " · ".join(code(n) for n in names if n) + "

    ") + if summary: + out.append(f'

    {esc(summary)}

    ') + if fields: + out.append('
    Fields
    ") + if methods: + out.append('
    Methods
    ') + out.extend(render_function(m) for m in methods) + out.append("
    ") + return "".join(out) + + +def render_alias(a): + name = a.get("name", "") + sig = a.get("signature", "") + doc = a.get("summary") or a.get("description") or "" + d = f' — {esc(doc)}' if doc else "" + return f'
  • {code(name)} = {code(sig)}{d}
  • ' if sig else f'
  • {code(name)}{d}
  • ' + + +def render_module(m): + name = m.get("name", "") + summary = m.get("summary") or m.get("description") or "" + aliases = m.get("aliases", []) + functions = m.get("functions", []) + structs = m.get("structs", []) + if not (aliases or functions or structs): + return "" + out = [f'

    {esc(name)}

    '] + if summary: + out.append(f'

    {esc(summary)}

    ') + if aliases: + out.append('
    Aliases
    ") + if structs: + out.extend(render_struct(s) for s in structs) + if functions: + out.append('
    Functions
    ') + out.extend(render_function(f) for f in functions) + out.append("
    ") + return "".join(out) + + +CSS = """ +:root{color-scheme:light dark;--bg:#fcfcfb;--fg:#0b0b0b;--muted:#57564f;--panel:#f3f3ef;--border:rgba(0,0,0,.1);--accent:#2a78d6;--code:#0b7285} +@media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#8b949e;--panel:#161b22;--border:rgba(255,255,255,.1);--accent:#57c5bb;--code:#7ee0d6}} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif} +.wrap{max-width:860px;margin:0 auto;padding:32px 20px 80px} +h1{font-size:1.9rem;margin:0 0 4px}.ver{color:var(--muted);margin:0 0 20px} +code{font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.85em;color:var(--code)} +#q{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--panel);color:var(--fg);font-size:1rem;margin-bottom:24px} +.module{border-top:1px solid var(--border);padding-top:8px;margin-top:24px} +h2{font-size:1.3rem;color:var(--accent)} +.item{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin:12px 0} +.item h3,.item h4{margin:0 0 8px;font-size:1.05rem} +.kind{font-size:.7rem;font-weight:600;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:1px 6px;margin-left:8px;vertical-align:middle} +.sig{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:.82rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;overflow-x:auto;white-space:pre;margin:6px 0} +.doc{color:var(--fg);margin:6px 0}.sub{font-size:.75rem;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin:14px 0 4px} +.traits{color:var(--muted);margin:2px 0 8px}.raises{color:var(--muted);font-size:.9em;margin:4px 0}.raises span{color:#d03b3b;font-weight:600} +ul{margin:4px 0;padding-left:20px}li{margin:3px 0}.hidden{display:none} +footer{margin-top:40px;color:var(--muted);font-size:.85rem;border-top:1px solid var(--border);padding-top:16px} +a{color:var(--accent)} +""" + +JS = """ +const q=document.getElementById('q'); +q.addEventListener('input',()=>{const v=q.value.toLowerCase(); +document.querySelectorAll('.item').forEach(el=>{ + el.classList.toggle('hidden', v && !(el.dataset.name||'').includes(v) && !el.textContent.toLowerCase().includes(v));}); +document.querySelectorAll('.module').forEach(m=>{ + const any=[...m.querySelectorAll('.item')].some(i=>!i.classList.contains('hidden')); + m.style.display=any||!v?'':'none';});}); +""" + + +def main(): + src, out = sys.argv[1], sys.argv[2] + d = json.load(open(src)) + decl = d.get("decl", {}) + pkg = decl.get("name", "package") + version = d.get("version", "") + summary = decl.get("summary") or decl.get("description") or "" + if os.path.dirname(out): + os.makedirs(os.path.dirname(out), exist_ok=True) + modules = "".join(render_module(m) for m in decl.get("modules", [])) + page = f""" + +{esc(pkg)} — API reference
    +

    {esc(pkg)}

    API reference{f' · {esc(version)}' if version else ''}

    +{f'

    {esc(summary)}

    ' if summary else ''} + +{modules} +
    Generated from mojo doc JSON by docs/render_api.py. No hand-written HTML.
    +
    """ + open(out, "w").write(page) + print(f"wrote {out} ({len(page)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/pixi.toml b/pixi.toml index 73c6cf2..14b0f93 100644 --- a/pixi.toml +++ b/pixi.toml @@ -18,6 +18,9 @@ test-connection = "mojo run -I src test/test_connection.mojo" # REDIS_PORT=6399 pixi run test-integration test-integration = "mojo run -I src test/test_integration.mojo" demo = "mojo run -I src examples/demo.mojo" +bench = "mojo build -I src bench/bench_resp.mojo -o .bench_resp && ./.bench_resp" +fmt = "mojo format src/ test/ examples/ bench/" +docs = "mojo doc -o docs/api.json -I src src/redis && python3 docs/render_api.py docs/api.json docs/api.html" [dependencies] # Must be `.dev0`, not `1.0.0b3`: a bare `1.0.0b3` sorts *after* the dev diff --git a/recipe.yaml b/recipe.yaml new file mode 100644 index 0000000..6cff865 --- /dev/null +++ b/recipe.yaml @@ -0,0 +1,39 @@ +# rattler-build recipe for publishing mojo-redis to a conda channel +# (e.g. modular-community). Not wired to CI yet — this documents the intended +# distribution shape and is ready to build the day we decide to publish. +# +# Distributes the pure-Mojo SOURCE TREE, not a compiled `.mojopkg`: a .mojopkg +# embeds the exact compiler version and refuses to load against any other, so a +# benign Mojo nightly bump would break every consumer. Shipping source + a +# runtime `mojo` pin gets reproducibility without ABI lock-in. (Pattern follows +# ehsanmok/flare's recipe.) +# +# Build locally with: rattler-build build --recipe recipe.yaml + +context: + version: "0.1.0" + +package: + name: mojo-redis + version: ${{ version }} + +source: + path: . + +build: + number: 0 + noarch: generic + script: + - mkdir -p ${{ PREFIX }}/lib/mojo + - cp -r src/redis ${{ PREFIX }}/lib/mojo/redis + +requirements: + run: + - mojo >=1.0.0b3.dev0,<2 + +about: + homepage: https://github.com/conorbronsdon/mojo-redis + repository: https://github.com/conorbronsdon/mojo-redis + license: MIT + license_file: LICENSE + summary: A Redis client for Mojo (RESP2), redis-py-shaped, over direct libc socket FFI diff --git a/src/redis/__init__.mojo b/src/redis/__init__.mojo index 1525f74..0a6c568 100644 --- a/src/redis/__init__.mojo +++ b/src/redis/__init__.mojo @@ -1,3 +1,5 @@ +"""RESP2 Redis client for Mojo, redis-py-shaped (mojo-redis).""" + from .resp import ( RespValue, ParseResult, diff --git a/src/redis/resp.mojo b/src/redis/resp.mojo index c277951..2bd5493 100644 --- a/src/redis/resp.mojo +++ b/src/redis/resp.mojo @@ -388,9 +388,7 @@ def _parse( items.append(ArcPointer(element^.take_value())) return ParseResult(True, pos - offset, RespValue.array(items^)) - raise Error( - "redis: unknown RESP type byte " + String(Int(kind_byte)) - ) + raise Error("redis: unknown RESP type byte " + String(Int(kind_byte))) def parse_reply(data: Span[UInt8, _]) raises -> ParseResult: diff --git a/test/test_resp.mojo b/test/test_resp.mojo index 7b6d6db..ca97887 100644 --- a/test/test_resp.mojo +++ b/test/test_resp.mojo @@ -166,9 +166,7 @@ def test_parse_mixed_array() raises: def test_parse_nested_array() raises: # *2 [ *2 [:1 :2] , $5 hello ] - var r = parse_reply( - Span(_bytes("*2\r\n*2\r\n:1\r\n:2\r\n$5\r\nhello\r\n")) - ) + var r = parse_reply(Span(_bytes("*2\r\n*2\r\n:1\r\n:2\r\n$5\r\nhello\r\n"))) assert_true(r.ok) assert_equal(r.value.kind, RESP_ARRAY) assert_equal(r.value.count(), 2)