From 7fc27cde73fcd8752f5c12060b0465cbdbcf5c08 Mon Sep 17 00:00:00 2001 From: Conor Bronsdon Date: Tue, 7 Jul 2026 20:14:11 -0700 Subject: [PATCH 1/2] style: commit canonical mojo format output --- examples/list_archive.mojo | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/list_archive.mojo b/examples/list_archive.mojo index 858acc7..169bfbc 100644 --- a/examples/list_archive.mojo +++ b/examples/list_archive.mojo @@ -31,7 +31,9 @@ def main() raises: print() for e in entries: - var line = _type_char(e.info) + " " + String(e.info.size) + "\t" + e.info.name + var line = ( + _type_char(e.info) + " " + String(e.info.size) + "\t" + e.info.name + ) if e.info.linkname.byte_length() > 0: line += " -> " + e.info.linkname print(line) From b58ff5be03ec3c22e18381934f6ce7ddcf002be2 Mon Sep 17 00:00:00 2001 From: Conor Bronsdon Date: Tue, 7 Jul 2026 20:40:21 -0700 Subject: [PATCH 2/2] Add API-doc site, format gate, recipe.yaml, bench (suite-wide quick wins) --- .github/workflows/docs.yaml | 49 +++++++++++ .github/workflows/test.yml | 5 ++ .gitignore | 4 + bench/bench_read.mojo | 42 +++++++++ docs/render_api.py | 165 ++++++++++++++++++++++++++++++++++++ pixi.toml | 3 + recipe.yaml | 39 +++++++++ src/tar/__init__.mojo | 2 +- 8 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docs.yaml create mode 100755 bench/bench_read.mojo create mode 100644 docs/render_api.py create mode 100755 recipe.yaml diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..9ad4040 --- /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/tar + 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 a3a5d38..54ad490 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,5 +23,10 @@ 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) + - name: Tests run: .venv/bin/mojo run -I src test/test_tar.mojo diff --git a/.gitignore b/.gitignore index 61545fb..63cfab4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ pixi.lock *.mojopkg __pycache__/ test/data/src_tree/ +.bench_read +docs/api.json +docs/api.html +docs/site/ diff --git a/bench/bench_read.mojo b/bench/bench_read.mojo new file mode 100755 index 0000000..29bace8 --- /dev/null +++ b/bench/bench_read.mojo @@ -0,0 +1,42 @@ +"""Throughput benchmark for the core read path: tar header parsing plus +member iteration, via the public `open_tar` API. + +Reads only the repo's own trusted fixtures in `test/data/` — no new parsing +logic lives here (tar size fields are attacker-controlled in the general +case, so the bench deliberately never touches untrusted input). The fixtures +are tiny (10 KiB each), so each archive is parsed many times per measurement +for stable numbers. Bytes are read from disk once; only parsing is timed. + +Run compiled for meaningful numbers: +`mojo build -I src bench/bench_read.mojo -o .bench_read && ./.bench_read` +(or `pixi run bench`). +""" +from std.time import perf_counter_ns + +from tar import open_tar + + +def bench(path: String, iterations: Int) raises: + var raw = open(path, "r").read_bytes() + var size_mb = Float64(len(raw)) / (1024.0 * 1024.0) + # Warmup + correctness anchor: member count must stay stable. + var warm = open_tar(Span(raw)) + var n = len(warm) + var start = perf_counter_ns() + for _ in range(iterations): + var entries = open_tar(Span(raw)) + if len(entries) != n: + raise Error("inconsistent parse") + var elapsed_ns = perf_counter_ns() - start + var per_parse_us = Float64(elapsed_ns) / Float64(iterations) / 1e3 + var mb_per_s = size_mb / (per_parse_us / 1e6) + print(path) + print(t" {len(raw)} bytes, {n} member(s):") + print(t" {per_parse_us} us/parse, {mb_per_s} MB/s") + + +def main() raises: + bench("test/data/ustar.tar", 5000) + bench("test/data/gnu.tar", 5000) + bench("test/data/pax.tar", 5000) + bench("test/data/dirs.tar", 5000) 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
      ') + out.extend(render_field(f) for f in fields) + out.append("
    ") + 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
      ') + out.extend(render_alias(a) for a in aliases) + out.append("
    ") + 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 ffded2d..ee03c9f 100644 --- a/pixi.toml +++ b/pixi.toml @@ -10,6 +10,9 @@ version = "0.1.0" test = "mojo run -I src test/test_tar.mojo" fuzz = "mojo run -I src test/fuzz_runner.mojo test/data/ustar.tar" demo = "mojo run -I src examples/list_archive.mojo test/data/ustar.tar" +bench = "mojo build -I src bench/bench_read.mojo -o .bench_read && ./.bench_read" +fmt = "mojo format src/ test/ examples/ bench/" +docs = "mojo doc -o docs/api.json -I src src/tar && python3 docs/render_api.py docs/api.json docs/api.html" [dependencies] mojo = ">=1.0.0b3.dev0,<2" diff --git a/recipe.yaml b/recipe.yaml new file mode 100755 index 0000000..05050ac --- /dev/null +++ b/recipe.yaml @@ -0,0 +1,39 @@ +# rattler-build recipe for publishing mojo-tar 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-tar + version: ${{ version }} + +source: + path: . + +build: + number: 0 + noarch: generic + script: + - mkdir -p ${{ PREFIX }}/lib/mojo + - cp -r src/tar ${{ PREFIX }}/lib/mojo/tar + +requirements: + run: + - mojo >=1.0.0b3.dev0,<2 + +about: + homepage: https://github.com/conorbronsdon/mojo-tar + repository: https://github.com/conorbronsdon/mojo-tar + license: MIT + license_file: LICENSE + summary: Pure-Mojo tar archive reader/writer mirroring Python's tarfile API diff --git a/src/tar/__init__.mojo b/src/tar/__init__.mojo index 207f49c..87013f8 100644 --- a/src/tar/__init__.mojo +++ b/src/tar/__init__.mojo @@ -1,4 +1,4 @@ -"""mojo-tar: pure-Mojo tar archive reading and writing (mirrors `tarfile`).""" +"""Tar archive reading and writing in pure Mojo, mirroring `tarfile` (mojo-tar).""" from tar.model import ( TarInfo,