diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100755 index 0000000..6576dbc --- /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/url + 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 d3beafc..abcdf7b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,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) + - name: Regenerate fixtures (must match committed) run: | python3 test/data/gen_fixtures.py > test/data/fixtures.txt diff --git a/.gitignore b/.gitignore index fc1e371..b0df644 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ pixi.lock *.mojopkg __pycache__/ +.bench_parse +docs/api.json +docs/api.html +docs/site/ diff --git a/bench/bench_parse.mojo b/bench/bench_parse.mojo new file mode 100755 index 0000000..26444b8 --- /dev/null +++ b/bench/bench_parse.mojo @@ -0,0 +1,74 @@ +"""Throughput benchmark for mojo-url's core paths. + +Times `urlparse` + `urlunparse` round-trips over the URL corpus, and +`parse_qsl` + `urlencode` round-trips over the query-string corpus. Both +corpora are extracted from `test/data/fixtures.txt` — the same CPython +byte-match conformance fixtures the test suite uses — so the benchmark +measures the real parse/encode paths on representative inputs. + +The corpus is tiny (dozens of short strings), so each benchmark iterates the +whole corpus many thousands of times for stable numbers. Run compiled for +meaningful results: +`mojo build -I src bench/bench_parse.mojo -o .bench_parse && ./.bench_parse` +(or `pixi run bench`). +""" +from std.time import perf_counter_ns + +from url import parse_qsl, urlencode, urlparse, urlunparse + + +def _inputs_for(source: String, op: String) -> List[String]: + """Collect the input column (field 1) of fixture lines tagged `op`.""" + var out = List[String]() + for line in source.split("\n"): + var fields = line.split("\t") + if len(fields) >= 2 and String(fields[0]) == op: + out.append(String(fields[1])) + return out^ + + +def bench_urlparse(urls: List[String], iterations: Int) raises: + # Warmup + correctness anchor: round-trip once, require stability. + var anchor = 0 + for url in urls: + anchor += urlunparse(urlparse(url)).byte_length() + var start = perf_counter_ns() + for _ in range(iterations): + var total = 0 + for url in urls: + total += urlunparse(urlparse(url)).byte_length() + if total != anchor: + raise Error("inconsistent parse") + var elapsed_ns = perf_counter_ns() - start + var per_op_us = Float64(elapsed_ns) / Float64(iterations * len(urls)) / 1e3 + print(t"urlparse + urlunparse: {len(urls)} URLs x {iterations} iters") + print(t" {per_op_us} us/round-trip") + + +def bench_query(queries: List[String], iterations: Int) raises: + # Warmup + correctness anchor: encode/decode once, require stability. + var anchor = 0 + for query in queries: + anchor += urlencode(parse_qsl(query)).byte_length() + var start = perf_counter_ns() + for _ in range(iterations): + var total = 0 + for query in queries: + total += urlencode(parse_qsl(query)).byte_length() + if total != anchor: + raise Error("inconsistent query round-trip") + var elapsed_ns = perf_counter_ns() - start + var count = iterations * len(queries) + var per_op_us = Float64(elapsed_ns) / Float64(count) / 1e3 + print(t"parse_qsl + urlencode: {len(queries)} queries x {iterations} iters") + print(t" {per_op_us} us/round-trip") + + +def main() raises: + var source = open("test/data/fixtures.txt", "r").read() + var urls = _inputs_for(source, "urlparse") + var queries = _inputs_for(source, "parse_qsl") + if len(urls) == 0 or len(queries) == 0: + raise Error("no fixture inputs found — run from the repo root") + bench_urlparse(urls, 20000) + bench_query(queries, 20000) 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/examples/parse_and_build.mojo b/examples/parse_and_build.mojo index a1dd02e..1c07811 100644 --- a/examples/parse_and_build.mojo +++ b/examples/parse_and_build.mojo @@ -50,8 +50,12 @@ def main() raises: var new_query = urlencode(pairs) var rebuilt = urlunparse( ParseResult( - r.scheme.copy(), r.netloc.copy(), r.path.copy(), - r.params.copy(), new_query^, r.fragment.copy(), + r.scheme.copy(), + r.netloc.copy(), + r.path.copy(), + r.params.copy(), + new_query^, + r.fragment.copy(), ) ) print() diff --git a/pixi.toml b/pixi.toml index 14157c5..c56e3eb 100644 --- a/pixi.toml +++ b/pixi.toml @@ -10,6 +10,9 @@ version = "0.1.0" test = "mojo run -I src test/test_url.mojo" demo = "mojo run -I src examples/parse_and_build.mojo \"https://user@host.example.com:8443/a/b?x=1&y=2#top\"" fixtures = "python3 test/data/gen_fixtures.py > test/data/fixtures.txt" +bench = "mojo build -I src bench/bench_parse.mojo -o .bench_parse && ./.bench_parse" +fmt = "mojo format src/ test/ examples/ bench/" +docs = "mojo doc -o docs/api.json -I src src/url && 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..e5f7657 --- /dev/null +++ b/recipe.yaml @@ -0,0 +1,39 @@ +# rattler-build recipe for publishing mojo-url 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-url + version: ${{ version }} + +source: + path: . + +build: + number: 0 + noarch: generic + script: + - mkdir -p ${{ PREFIX }}/lib/mojo + - cp -r src/url ${{ PREFIX }}/lib/mojo/url + +requirements: + run: + - mojo >=1.0.0b3.dev0,<2 + +about: + homepage: https://github.com/conorbronsdon/mojo-url + repository: https://github.com/conorbronsdon/mojo-url + license: MIT + license_file: LICENSE + summary: URL parsing, building, and percent/query-string encoding in pure Mojo, mirroring Python's urllib.parse diff --git a/src/url/__init__.mojo b/src/url/__init__.mojo index 6977ab6..2a9f7b4 100644 --- a/src/url/__init__.mojo +++ b/src/url/__init__.mojo @@ -1,4 +1,4 @@ -"""mojo-url: URL parsing, building, and percent/query-string encoding. +"""URL parsing, building, and percent/query-string encoding (mojo-url). A pure-Mojo mirror of Python's `urllib.parse` — same function names, same byte-for-byte output — with an RFC 3986 Section 5 conformant `urljoin`. diff --git a/src/url/model.mojo b/src/url/model.mojo index 6972de2..8424248 100644 --- a/src/url/model.mojo +++ b/src/url/model.mojo @@ -16,7 +16,7 @@ zone id after `%` preserved, matching Python. @fieldwise_init -struct QueryPair(Copyable, Movable, Writable, Equatable): +struct QueryPair(Copyable, Equatable, Movable, Writable): """One `key=value` pair from a query string (decoded).""" var key: String @@ -30,7 +30,7 @@ struct QueryPair(Copyable, Movable, Writable, Equatable): @fieldwise_init -struct ParseResult(Copyable, Movable, Writable, Equatable): +struct ParseResult(Copyable, Equatable, Movable, Writable): """The six-tuple split of a URL, plus netloc-derived accessors.""" var scheme: String