Repository navigation
Download DeltaTrack as one HTML file that compares bill XML offline - #844
banjtheman wants to merge 9 commits into
Conversation
`scripts/pyodide_parity.py` (the check that the XML comparison gives byte-identical output under Pyodide) stopped loading the engine after civictechdc#794. That change wraps two `pypdfium2.raw` bindings with ctypes when `parsers/pdf_text.py` is imported, and the check's catch-all tripwire stub sends `ctypes.cast` into endless `_as_parameter_` lookups, so Pyodide died with "Maximum call stack size exceeded" before comparing anything. Nothing noticed because the check is not in CI. The `raw` stub now hands out real ctypes function pointers that call back into Python and raise. With it, 3 fixtures x 2 artifacts are IDENTICAL between CPython 3.12 and Pyodide 314.0.7, and `--mutate` still reports MISMATCH on every HTML row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Someone who can't install Python had two ways to see a comparison: the published examples, or the hosted app, which uploads their files to a server and so can't take a confidential draft (ADR 0011). This adds a third: `scripts/build_offline_html.py` writes `deltatrack-offline.html`, one 8.8 MB file carrying Pyodide 314.0.7 and the `deltatrack` package. Opened from disk, it compares two bill XML files with the same `compare_xml_html` call the CLI makes, shows the report in a frame, saves it with a download, and makes no network requests. A page opened from `file://` may not fetch a neighbouring file, so every asset is inlined (gzip, then base64) and passed in through Pyodide's own options where one exists; the wasm binary and the standard library are answered from a fetch wrapper that refuses every other request. XML only: `pypdfium2` has no Pyodide build, so the bundle carries stand-ins (the XML path still imports it, civictechdc#751) and the page refuses a PDF up front. `tests/test_offline_html.py`: fast tests that every asset the page unpacks is built into it, the Pyodide tarball pin is enforced, the bundle imports the XML path with PDFium stubbed, and every token the page uses survives the stylesheet's token pruning; plus a browser test (also `network`, declared in ALLOWED_DEFAULT_SKIPS) that opens a real build from disk and requires a report byte-identical to the CLI's with no off-device requests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The home page needs one address that always serves the current offline page. `publish-offline.yml` runs on push to `main`, like the hosted app and the example reports, tests the page from disk in Chromium, builds it, and attaches it to the rolling `offline-latest` release: releases/download/offline-latest/deltatrack-offline.html. A failed test stops the job before the upload, so the previous build stays up. Not the Pages job, which publishes committed files and must not build anything (civictechdc#42, civictechdc#547); not a committed file, which would be 9 MB rewritten on every engine change. The job needs `contents: write` to create the release, move its tag and replace the asset. docs/release.md now lists three public surfaces and five post-merge runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The left card said "Browser-only: Coming soon". It is now "Download and run it yourself", linking to the offline-latest release asset, and says what the file does and doesn't do (bill XML only for now). The upload page points people with drafts at the offline version instead of saying one is planned. The three rules only the disabled card used are gone, and the regenerated token block drops `--muted`, which nothing on the upload pages uses now. `contrast.toml` no longer names the "Coming soon" badge. docs/web-compare.md describes the card and adds a section on the offline page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Check the Pyodide tarball's hash before caching it, so a bad download is never kept and refused on every later build. - Never cancel an in-progress publish: a run stopped between deleting the old asset and uploading the new one would leave the release with no file. - Release the decompressed wasm and stdlib once Pyodide has copied them, instead of holding about 12 MB for the page's lifetime. - The fast engine-bundle test compares an inline two-section bill rather than a committed bill pair, so the fast group stays off real bill XML. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@banjtheman, thanks for getting the offline delivery path working! I tested with Chromium 138: all five offline-page tests passed on the PR branch and its merge with current I found one small compatibility issue before approval: Please let the XML parser handle supported encodings, while preserving the clear PDF rejection. The behavior to preserve is that supported bill XML compares identically through the offline and native paths. A focused browser regression using UTF-16 input should verify that equality; restoring the current ASCII-only check should make it fail. Once that correction passes the focused checks, this looks ready for approval. PDF support can remain separate. *Comment/evaluation from GPT Sol 6.1 Effort High |
willhea
left a comment
There was a problem hiding this comment.
I built this branch locally and ran it in a browser. It does what the description says. The report matched compare_xml_html byte for byte (SHA-256) on 118-hr-4366 v1 to v2 (0.1 s) and v3 to v4 (0.7 s), and startup took 1.5 s. Bad input gets a clear message every time and the page recovers afterwards: a PDF, a PDF renamed to .xml, non-bill XML and malformed XML all behave. The only things the page itself loaded were its HTML and two blob: module imports.
The browser pane I used can't open file://, so I served dist/ from http://localhost for the manual run. The from-disk case is covered by test_the_page_opened_from_disk_matches_the_command_line, which ran and passed here with --run-browser --run-network, and was not skipped. The fast tier gives 2291 passed, the same count as in the description, and ruff check and ruff format --check are clean.
The inline comments below are ordered by severity. Three are marked Confirmed locally: I reproduced those in the browser. The rest come from reading the diff. The first two are the ones I'd want settled before this merges, since the whole point of the page is that confidential drafts stay on the machine.
🤖 Generated with Claude Code
| """Zip the installed ``deltatrack`` package, plus the PDFium stub, for Pyodide to unpack.""" | ||
| buf = io.BytesIO() | ||
| with zipfile.ZipFile(buf, "w") as zf: | ||
| for path in sorted(package_dir.rglob("*")): |
There was a problem hiding this comment.
engine_zip decides what goes in the bundle by globbing the package directory. Hatchling already defines what the package ships. Globbing src/deltatrack means a local build picks up whatever untracked files are sitting there, such as scratch files, .DS_Store or local data, and puts them into a file meant for distribution. CI builds from a clean checkout, so this mostly affects local builds.
The bundle also has no dist-info. That means importlib.metadata.version("deltatrack") (used in pdf_observations.py:215) would raise PackageNotFoundError under Pyodide if any XML-path code ever reached it. Building with uv build --wheel and unpacking the wheel fixes both problems. Low priority.
There was a problem hiding this comment.
Agreed, but left for a follow-up since you marked it low priority. CI builds from a clean checkout, so the published page isn't affected today. I can fold it in here if you'd rather.
…6; sandbox the report From willhea's review of civictechdc#844 (comment and review threads): - The page now carries a Content-Security-Policy allowing no connection, frame, font or object source (default-src 'none'; scripts only inline, blob: and 'wasm-unsafe-eval'). The fetch wrapper only blocked fetch: XMLHttpRequest, and fetch from inside the report frame, reached the network. The srcdoc report frame inherits the policy. Pyodide boots under it without 'unsafe-eval' (1.4 s). - The report frame is sandboxed (allow-scripts allow-downloads, no allow-same-origin, allow="clipboard-write"), so the report's script, which shows text from the user's files, cannot reach the page's runtime or the picked files. Its filters, find, diff.json download and clipboard copy all still work. - The ASCII-only `looksLikeXml` pre-check is gone: a UTF-16 bill the engine compares natively was refused with "doesn't look like an XML file". The XML parser now decides; a PDF is still refused, by name or by its "%PDF-" header (which also catches one renamed to .xml). - The "Download for offline use" branch is gone: it showed only when the page was served over http(s), and the only published address is a release asset GitHub serves as a download. - Assets decode with Uint8Array.fromBase64 where the browser has it. Tests: fast checks that the page declares the policy and the sandbox; the from-disk parity test now runs for UTF-8 and UTF-16 bills (UTF-16 goes red with the ASCII-only check restored); a new browser test makes the page and the report frame try fetch, XHR and an image against a local server and requires that nothing arrives and that the frame cannot read the page (red without the policy, and red without the sandbox). The browser tests read attributes instead of using Playwright's eval-based helpers, which the policy forbids. `--offline-page` lets them test a prebuilt file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ide in PR CI From willhea's review of civictechdc#844: - publish-offline.yml is two jobs. `build` (contents: read) installs the dependencies, builds the page once, tests that file from disk with `--offline-page`, and hands it over as an artifact. `publish` (contents: write) downloads it and runs only `gh`. The write token no longer sits beside dependency or test code, and the uploaded file is the tested one, built once. - A failed upload no longer leaves the release without a file: the new file goes up beside the old one under a temporary name (retried three times), then the old asset is deleted and the new one renamed, and only then does the tag move to the commit the file was built from. - CI's browser step passes --run-network and caches ~/.cache/deltatrack (keyed on the build script, where the pin lives; the build's hash check makes a cached tarball safe). The offline page's tests, the only ones that run the engine under Pyodide, now run on every pull request rather than first at the publish after promotion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…job publish docs/web-compare.md: the policy and the sandboxed report frame, UTF-16 input, the two-job publish with the safe swap, and the size described as several megabytes instead of a figure that changes with every engine or Pyodide change (the home page card keeps its figure). TESTING.md: CI's browser step now runs the offline page's network tests. docs/release.md: the publish table row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@willhea, thank you for building it, running it in a browser and reproducing three of these. All three commits are on the branch now (b3b2813, 792221f, f31d026), on top of a merge of current develop:
The other items are answered inline. One is deferred: Gates on the final tree:
Opened from 🤖 Generated with Claude Code |
Related issue
Closes #843
Refs #112 (open decision on how DeltaTrack reaches staffers). This ships one of the channels listed there, a fully in-browser page with no server, but doesn't make the decision.
What does this change?
DeltaTrack can now be downloaded as one HTML file (about 9 MB) that compares two versions of a bill's XML on the user's own computer. They double-click it and pick the older and newer versions. It needs no install and no internet, and nothing is uploaded. The report is the same one the command line writes, byte for byte. This gives people who can't install Python a way to compare confidential drafts, which the hosted upload app can't take (ADR 0011: user-provided bill content never leaves the user's machine).
scripts/build_offline_html.pywritesdeltatrack-offline.htmlfromweb/offline/template.html. It inlines Pyodide 314.0.7 (Python compiled to WebAssembly; the npm tarball is hash-pinned) plus thedeltatrackpackage and the upload pages' stylesheet.file://may not fetch neighbouring files. So assets are gzip+base64 inlined and handed to Pyodide through its own options (createPyodideModule,lockFileContents).fetchwrapper.default-src 'none'andconnect-src 'none', with only the inline-script,blob:and'wasm-unsafe-eval'allowances Pyodide needs. The report frame is sandboxed withoutallow-same-origin, so its script can't reach the page.%PDF-bytes. UTF-16 bill XML compares like UTF-8.pypdfium2has no Pyodide build, so the bundle carries stand-ins (the XML path still imports it, The XML comparison cannot load in a browser because it imports a PDF library it never uses #751) and the page refuses a PDF up front..github/workflows/publish-offline.yml. On push tomainit attaches the file to a rollingoffline-latestrelease. That gives a stable one-click link:releases/download/offline-latest/deltatrack-offline.html.buildjob withcontents: readbuilds the file once, tests that exact file (--offline-page) and passes it on as an artifact. Apublishjob holdscontents: writeand runs nothing butgh.contents: write. That's the outward-facing part, and I'd appreciate asecuritylook per AGENTS.md (I can't add labels).ci.yml's browser job caches the hash-pinned Pyodide tarball and runs the network-marked browser tests too, so a develop change that breaks the page fails before promotion.--muted.parsers/pdf_text.pyis imported. That broke loading the engine under Pyodide, and it brokescripts/pyodide_parity.pytoo, which isn't in CI. The first commit gives the parity check apypdfium2.rawstub that ctypes can wrap. It's a separate commit, so it can come out into its own PR if you'd rather.docs/release.mdnow lists three public surfaces and five post-merge runs, and describes the two-job publish.docs/web-compare.mdhas a section on the offline page.scripts/README.mdandTESTING.mdare updated too.Deliberately left out:
How to test
What I ran and looked at (macOS, Chromium via Playwright; updated after review):
file://(originnull), under the Content-Security-Policy: ready in about 1.4 s.compare_xml_htmlwith the filename-derived labels, from UTF-8 and from UTF-16 input.file:/blob:. With a local server counting hits,fetch,XMLHttpRequestand anImageload from the page and from the report frame all reach it zero times, and the report can't readparent.document.diff.jsondownload and Copy work inside the sandboxed frame.uvicorn web.app:app --port 8077. The left card reads "Download and run it yourself".scripts/pyodide_parity.py --node-dir <dir>on develop fails with "Maximum call stack size exceeded" inctypes.castfrompdf_text.py:575. On this branch: 3 fixtures × 2 artifacts IDENTICAL, and--mutatereports MISMATCH on every HTML row.test_the_engine_bundle_imports_the_xml_path_with_pdfium_stubbedred.PYODIDE_ASSETSturnstest_every_asset_the_page_unpacks_is_built_into_itred.ruff check,ruff format --check: clean.--run-browser --run-network, as CI now runs it): 33 passed. With--run-browseralone: 30 passed and 3 skipped, the network tests declared inALLOWED_DEFAULT_SKIPS.-m slow --deselect tests/test_govinfo_corpus_parity.py): 2231 passed.Not verified:
main.Known follow-ups, also raised in review:
publish-offline.ymlhas run once onmain.engine_zipglobssrc/deltatrackrather than unpacking the built wheel, so a local build can pick up untracked files, and the bundle has nodist-info(low priority in review; CI builds from a clean checkout).I can't attach images from the CLI; I'll drag before/after screenshots of the home page and a short demo video into a comment.
Checklist
Closes #...)AI assistance
Written with Claude Code (Claude Opus 5.5) at Civic Tech DC night; I reviewed the change and ran the checks above.
🤖 Generated with Claude Code