diff --git a/.github/workflows/ci-image.yml b/.github/workflows/ci-image.yml index e593366e43e..099e40d71b6 100644 --- a/.github/workflows/ci-image.yml +++ b/.github/workflows/ci-image.yml @@ -503,9 +503,9 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - # A cold build installs pandas, numpy, matplotlib, seaborn, plotnine and - # altair with vl-convert; 20 fails loudly instead of hanging on a slow - # package index. + # A cold build installs pandas, numpy, matplotlib, seaborn, plotnine, + # altair with vl-convert, pygal with cairosvg and lets-plot, plus libcairo + # from apt; 20 fails loudly instead of hanging on a slow package index. timeout-minutes: 20 steps: @@ -733,6 +733,167 @@ jobs: EOF tail -n 1 work-altair/harness.out + # pygal (step 1c of agentic/specs/261024-agent-all-libraries.md) runs in process with the + # seaborn step's flags, as its row of agents/renderer/commands.py keeps the Python + # defaults. Its PNG is cairosvg's: libcairo from the image's apt layer, loaded before the + # guard (warm_cairo in harness.py), and cssselect2's selector lambdas (CODE_BUILDERS). + # The HTML twin is the chart's SVG, written by render_to_file; pygal writes no probe. + # Two forms: render_to_png on a 3200x1800 chart, and the form the normaliser's canvas + # rewrite gives oversized charts (agents/anyplot/code/normalise.py; 94 catalogue files + # on 2026-10-26, 89 of them 4800x2700): the plot itself imports cairosvg and calls + # svg2png at 3200x1800. + - name: Render a pygal plot through the harness + if: steps.check.outputs.should_build == 'true' + run: | + set -euo pipefail + render() { # $1: work dir with plot.py and data.csv + local work=$1 + docker run --rm --network none --read-only --tmpfs /tmp \ + --cap-drop ALL --security-opt no-new-privileges --user "$(id -u):$(id -g)" \ + -v "$PWD/$work:/work" -w /work \ + -e ANYPLOT_THEME=light -e MPLBACKEND=Agg -e MPLCONFIGDIR=/tmp/mpl -e ANYPLOT_MPL_SEED=/opt/mplconfig \ + -e HOME=/tmp -e PYTHONDONTWRITEBYTECODE=1 -e OPENBLAS_NUM_THREADS=1 -e OMP_NUM_THREADS=1 \ + -e ANYPLOT_RLIMIT_CPU_S=60 -e ANYPLOT_RLIMIT_FSIZE_MB=50 -e ANYPLOT_RLIMIT_AS_MB=1024 \ + anyplot-renderer:ci /app/.venv/bin/python -I -X disable-remote-debug /opt/anyplot/harness.py plot.py > "$work/harness.out" + grep -qxF 'HARNESS {"event": "start"}' "$work/harness.out" || { echo "::error::no HARNESS start line in $work"; cat "$work/harness.out"; exit 1; } + grep -q '^HARNESS {"event": "end"' "$work/harness.out" || { echo "::error::no HARNESS end line in $work"; cat "$work/harness.out"; exit 1; } + python3 - "$work" <<'EOF' + import struct + import sys + + work = sys.argv[1] + png = open(f"{work}/plot-light.png", "rb").read() + assert png[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG" + width, height = struct.unpack(">II", png[16:24]) + assert (width, height) == (3200, 1800), (width, height) + html = open(f"{work}/plot-light.html", encoding="utf-8").read() + assert "pygal-chart" in html, "plot-light.html holds no pygal chart" + print(f"OK: {work} plot-light.png {width}x{height}, {len(png)} bytes; plot-light.html {len(html)} characters") + EOF + tail -n 1 "$work/harness.out" + } + mkdir -p work-pygal work-pygal-canvas + printf 'x,y\na,1\nb,3\nc,2\n' > work-pygal/data.csv + cp work-pygal/data.csv work-pygal-canvas/data.csv + cat > work-pygal/plot.py <<'EOF' + import os + + import pandas as pd + import pygal + + THEME = os.environ["ANYPLOT_THEME"] + df = pd.read_csv("data.csv") + chart = pygal.Bar(width=3200, height=1800, title="pygal", x_title="x", y_title="y") + chart.x_labels = list(df["x"]) + chart.add("y", [float(value) for value in df["y"]]) + chart.render_to_png(f"plot-{THEME}.png") + chart.render_to_file(f"plot-{THEME}.html") + EOF + cat > work-pygal-canvas/plot.py <<'EOF' + import os + + import cairosvg + import pandas as pd + import pygal + + THEME = os.environ["ANYPLOT_THEME"] + df = pd.read_csv("data.csv") + chart = pygal.Bar(width=4800, height=2700, title="pygal", x_title="x", y_title="y") + chart.x_labels = list(df["x"]) + chart.add("y", [float(value) for value in df["y"]]) + cairosvg.svg2png(bytestring=chart.render(), write_to=f"plot-{THEME}.png", output_width=3200, output_height=1800) + chart.render_to_file(f"plot-{THEME}.html") + EOF + render work-pygal + render work-pygal-canvas + + # lets-plot (step 1c) rasterises in its native bridge, which needs more address space + # than the Python default: its row of agents/renderer/commands.py sets RLIMIT_AS to + # 2048 MiB with Arrow's system allocator, and points the bridge's own fontconfig at the + # image's /opt/anyplot/fonts.conf. This run takes that row's environment and limit from + # the command table itself (commands.py imports only the standard library, so the + # runner's Python reads it), in the order the sandbox's environment has them. Every + # other flag is the seaborn step's. Without the fontconfig file every export writes + # "Fontconfig error" to stderr, which is the extra assert. No probe. + - name: Render a lets-plot plot through the harness + if: steps.check.outputs.should_build == 'true' + run: | + set -euo pipefail + mkdir -p work-letsplot + python3 - > work-letsplot/row.flags <<'EOF' + from agents.renderer.commands import command_for + + row = command_for("python", "letsplot") + flags = [f"{key}={value}" for key, value in row.env.items()] + if row.rlimit_as: + flags.append(f"ANYPLOT_RLIMIT_AS_MB={row.rlimit_as_mb or 1024}") + for flag in flags: + print(f"-e\n{flag}") + EOF + mapfile -t ROW_FLAGS < work-letsplot/row.flags + test "${#ROW_FLAGS[@]}" -gt 0 || { echo "::error::the letsplot row of commands.py gave no flags"; exit 1; } + echo "the letsplot row: ${ROW_FLAGS[*]}" + printf 'x,y\na,1\nb,3\nc,2\n' > work-letsplot/data.csv + cat > work-letsplot/plot.py <<'EOF' + import os + + import pandas as pd + from lets_plot import LetsPlot, aes, geom_bar, ggplot, ggsize, labs + from lets_plot.export import ggsave + + LetsPlot.setup_html() + + THEME = os.environ["ANYPLOT_THEME"] + df = pd.read_csv("data.csv") + plot = ggplot(df, aes(x="x", y="y")) + geom_bar(stat="identity") + labs(title="lets-plot") + ggsize(800, 450) + ggsave(plot, f"plot-{THEME}.png", path=".", scale=4) + ggsave(plot, f"plot-{THEME}.html", path=".") + EOF + docker run --rm --network none --read-only --tmpfs /tmp \ + --cap-drop ALL --security-opt no-new-privileges --user "$(id -u):$(id -g)" \ + -v "$PWD/work-letsplot:/work" -w /work \ + -e ANYPLOT_THEME=light -e MPLBACKEND=Agg -e MPLCONFIGDIR=/tmp/mpl -e ANYPLOT_MPL_SEED=/opt/mplconfig \ + "${ROW_FLAGS[@]}" \ + -e HOME=/tmp -e PYTHONDONTWRITEBYTECODE=1 -e OPENBLAS_NUM_THREADS=1 -e OMP_NUM_THREADS=1 \ + -e ANYPLOT_RLIMIT_CPU_S=60 -e ANYPLOT_RLIMIT_FSIZE_MB=50 \ + anyplot-renderer:ci /app/.venv/bin/python -I -X disable-remote-debug /opt/anyplot/harness.py plot.py \ + > work-letsplot/harness.out 2> work-letsplot/harness.err \ + || { echo "::error::the lets-plot render failed"; cat work-letsplot/harness.out work-letsplot/harness.err; exit 1; } + grep -qxF 'HARNESS {"event": "start"}' work-letsplot/harness.out || { echo "::error::no HARNESS start line"; cat work-letsplot/harness.out; exit 1; } + grep -q '^HARNESS {"event": "end"' work-letsplot/harness.out || { echo "::error::no HARNESS end line"; cat work-letsplot/harness.out; exit 1; } + if grep -q 'Fontconfig error' work-letsplot/harness.err; then + echo "::error::lets-plot's fontconfig found no configuration"; cat work-letsplot/harness.err; exit 1 + fi + python3 - <<'EOF' + import struct + png = open("work-letsplot/plot-light.png", "rb").read() + assert png[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG" + width, height = struct.unpack(">II", png[16:24]) + assert (width, height) == (3200, 1800), (width, height) + html = open("work-letsplot/plot-light.html", encoding="utf-8").read() + assert "data-lets-plot-script" in html, "plot-light.html holds no lets-plot chart" + print(f"OK: lets-plot plot-light.png {width}x{height}, {len(png)} bytes; plot-light.html {len(html)} characters") + EOF + tail -n 1 work-letsplot/harness.out + + # Native code the audit hook cannot see finds no program to run. lets-plot's bridge links + # ImageMagick with a compiled-in delegate map that runs programs by name through /bin/sh + # (gs, curl, soffice, ffmpeg, rsvg-convert, inkscape and the rest that + # agents/renderer/delegates.py lists). That ImageMagick is a zero-configuration build: it + # reads no policy.xml, so no policy file can switch the delegates off (an export with a + # policy denying PNG under MAGICK_CONFIGURE_PATH still wrote its PNG, 2026-10-26). + # matplotlib calls fc-list when it finds it. So the sandbox's PATH (SANDBOX_PATH of + # agents/renderer/executor.py) must carry none of them, next to RLIMIT_NPROC and the + # sandbox itself. node stays on the list until the JavaScript runtime (step 3 of + # agentic/specs/261024-agent-all-libraries.md) installs it as its row's toolchain. + - name: No delegate program on the sandbox PATH + if: steps.check.outputs.should_build == 'true' + run: | + set -euo pipefail + # The image build ran the same check (agents/renderer/Dockerfile); this runs it + # once more in the image the job just built, as the sandbox would see it. + docker run --rm --network none anyplot-renderer:ci /app/.venv/bin/python -m agents.renderer.delegates + # The file conversion inside the image (harness.py --convert): every format the # page sends, with the image's slimmed pyarrow (owner decision of 2026-10-15), # its calamine and NumPy, the guard's preload, and the sandbox's environment. @@ -820,7 +981,8 @@ jobs: EOF # The numbers the font set costs, for the PR and the deploy notes: the image - # size, the base release, the installed files and their package versions. + # size, the base release, the installed files and their package versions, + # libcairo's apt layer (pygal) among them. - name: Image size, base release and installed fonts if: steps.check.outputs.should_build == 'true' run: | @@ -828,6 +990,7 @@ jobs: echo "image size: $(docker image inspect --format '{{.Size}}' anyplot-renderer:ci) bytes" docker run --rm anyplot-renderer:ci du -sh /app/.venv/lib/python3.14/site-packages/pyarrow docker run --rm anyplot-renderer:ci sh -c 'grep PRETTY_NAME /etc/os-release; cat /usr/share/doc/fonts-noto/versions.txt; ls -l /usr/share/fonts/noto; du -sh /usr/share/fonts/noto; head -n 5 /usr/share/doc/fonts-noto/fonts-noto-core.copyright' + docker run --rm anyplot-renderer:ci dpkg-query -W libcairo2 libfontconfig1 fontconfig-config fonts-dejavu-core fonts-dejavu-mono # Also after a failed assert, so the PNGs show what went wrong. - name: Upload the multi-script render diff --git a/agentic/specs/261024-agent-all-libraries.md b/agentic/specs/261024-agent-all-libraries.md index 69eaaebae6c..442aafdf991 100644 --- a/agentic/specs/261024-agent-all-libraries.md +++ b/agentic/specs/261024-agent-all-libraries.md @@ -1,9 +1,10 @@ # Feature: "Use with my data" for all 15 catalogue libraries -> **Status (2026-10-25):** plan, in progress. Step 1a (the runtime seam) is open as -> PR #12404. Step 1b (plotnine and altair) is implemented on its branch and waits for -> 1a's merge; see "What 1b built" under step 1b. The deploy serves matplotlib and seaborn: -> plotnine and altair have runtimes after 1b, but `AGENT_LIBRARIES` keeps its default +> **Status (2026-10-26):** plan, in progress. Steps 1a (the runtime seam) and 1b +> (plotnine and altair) merged with PR #12404; see "What 1b built" under step 1b. Step 1c +> (pygal and lets-plot) is implemented on its branch, and its PR follows; see "What 1c +> built" under step 1c. The deploy serves matplotlib and seaborn: plotnine, altair, pygal +> and lets-plot have runtimes, but `AGENT_LIBRARIES` keeps its default > `matplotlib,seaborn` until each one's matrix run passes. Every other library in this > document is planned, not shipped. @@ -144,8 +145,9 @@ What the code does today, and what has to change: three deny lists share. - `agents/renderer/toolchain.Dockerfile` (owner question 1): Ubuntu 24.04 with R from rocker's layout and Posit binaries, Python 3.14 managed by uv, Node 22, Playwright - Chromium, Julia 1.13 and the precompiled depot; published to GHCR by a workflow that - runs on changes to the lock files. + Chromium, Julia 1.13 and the precompiled depot; built by a Cloud Build trigger on + changes to the lock files and pushed to the private Artifact Registry repository + `anyplot` in `europe-west4`, next to the renderer image (owner question 1). - `agents/evals/fixtures/cases/--/` for the 13 libraries (generated). - `tests/unit/agents/code/test_profiles_*.py`, `tests/unit/agents/renderer/test_commands.py`, @@ -354,6 +356,310 @@ reasons (a file can have several causes): rewritten by the normaliser to `ggsave(..., path=".")`, the form the other 301 use. The slim image needs `libcairo2` for cairosvg. +#### What 1c built (2026-10-26) + +- **Two more save rules.** `regions.SAVE_RULES` gains pygal: `render_to_png` (the file + as its positional or `filename=`, plus `dpi=`), `render_to_file` for the HTML twin + (`filename` only), and `cairosvg.svg2png`, which takes every argument by keyword + (`SaveRule.keyword_only`: the file is `write_to=`). lets-plot saves through `ggsave` + with the file second and `path="."` on every save (`SaveRule.directory`, the one + literal every save passes). `SaveRule.writes` pairs a save name with the one file + type it writes (pygal: `render_to_png` and `svg2png` only the PNG, `render_to_file` + only the HTML; `SaveRule.writes_suffix`), which the validator, the readiness scan and + the canvas step check. `SaveRule.canvas` names each library's canvas rewrite + (`figure`, `pygal`, `letsplot`, or none for plotnine and altair). `SAVE_NAMES` gains + `render_to_png`, `render_to_file` and `svg2png` in every profile, and `PythonRuntime` + serves `PYTHON_LIBRARIES`: matplotlib, seaborn, plotnine, altair, pygal and + `letsplot`. +- **Validator.** `LIBRARY_IMPORTS` gains pygal (`pygal`, `cairosvg`) and lets-plot + (`lets_plot`), after a walk of both public surfaces that `REVIEWED_SUBMODULES` pins + (pygal 3.1.3, cairosvg 2.9.1, lets-plot 4.11.0, `lets_plot.plot` included). + - pygal: `cssselect2`, `tinycss2`, `webencodings` and `cairocffi` are unreachable, + and so are cairosvg's `Path`, `parser`, `surface`, `url`, `image`, `css`, + `features`, `test_api` and `ROOT`; pygal's `etree` is an unsafe submodule; + `LIBRARY_BANS["pygal"]` holds the browser, pyquery and tree renderers, + `add_xml_filter`, cairosvg's PDF, PS, EPS and SVG exporters, the cairo surfaces, + the fetchers, `cut` and `_update` (which merges any dict into the chart's config). + The config rule: `defs=` is refused, `js=` takes only `[]` or `()`, `css=` takes + only inline entries and pygal's three package stylesheets (exempt from the URL rule + at that node only), `.css` and `.js` are only ever the single target of a plain + assignment, every `**` is a dict literal with string keys or a name bound once and + used only as `**name`, and a `googlefont:` string is a URL literal. The SVG namespace names are exempt from the + URL rule in pygal's profile only. `file_obj`, `unsafe` and `url_fetcher` are banned + keywords on every call. + - lets-plot: `geo_data`, `tilesets`, `settings_utils`, `frontend_context` and + `plot.geom_livemap_` are denied, its 23 helpers (geocoding, map tiles, `to_png`, + `to_svg`, `to_pdf`, `setup_show_ext`, `LazyModule` and others) are unsafe + everywhere, and `LetsPlot.set`, `setup_show_ext` and `cfg` are module escapes. + `LetsPlot` is only ever the receiver of a `setup_html()` with no arguments, and + `.props` is banned. The `.format` exemption for builder chains is described in the + table below. + - Every profile: the save-name rule (`_check_save_names`), also inside plotnine's + expression strings and in the DATA profile; plotnine's expression rule counts a + name's bindings strictly (`_bindings`, match captures included). The SECURITY + profile of every library pins `THEME` to its theme block and `os` to its import + (`_check_theme_bindings`; see the table below), and a pygal save whose name does + not write its target's file type is a `savefig-target` finding. +- **Normaliser.** Four guard rules for every library (`importlib.util.find_spec` + tests, a `try` whose `finally` only restores `sys.path`, `del sys.modules[...]` and + `sys.dont_write_bytecode` in a guard, `import_module("m").ATTR`). For pygal: the + html-open rewrite (158 files), html-drop with its feeder cleanup (103 files, feeders + in 41) and the canvas rewrite (94 files: 76 `render_to_png` saves become `svg2png` + with `import cairosvg` added, 13 `svg2png` saves get the output size, and 5 hand-built + SVGs get it from their root ``; 89 land at 3200x1800 and 5 at 2400x2400). A + chart's size and an output size may be a module-level name bound once to a number; a + size set after the constructor leaves the save alone with a note. For lets-plot: + the `export_ggsave` alias rename (17 files), the star-import expansion (151 files, + from `letsplot_names.py`, a static copy of `lets_plot.__all__` 4.11.0 that a test + pins, in ruff's natural import order; a name the file may read before it binds it + keeps the star import with a note), `path="."` on 62 saves in 31 files, 25 epilogues + removed, one returned path unwrapped, the `f"{d}/plot-{THEME}.png"` target form, and + the scale-only canvas rewrite (117 files; mosaic-categorical fits inside the target + at 3000x1800 with `scale=1.875`, and three grids and two aspects keep a note). The + header step strips every catalogue header at the top, because three lets-plot files + carry two. The readiness scan reports a missing `path="."` and a save name that does + not write its target's file type as `savefig-target` reasons, and for a pygal or + lets-plot canvas of unknown size says that R2 refuses a PNG over its pixel cap + instead of promising R3's padding. The normalised matplotlib and seaborn output is + byte-identical to main on all 650 files, and all 1,946 catalogue files of the six + libraries normalise idempotently. +- **Harness.** `warm_cairo` imports cairosvg before the guard is armed when the plot + names pygal or cairosvg. `CODE_BUILDERS` gains `("cssselect2/compiler.py", + "__init__")`, the compiler that turns each CSS selector of a pygal SVG into one + `eval("lambda el: ...")` (87 per chart) and quotes every string the CSS controls with + `repr`; its docstring holds the security argument. lets-plot needed no harness change: + its native bridge loads at import, and its guarded renders wrote only the two + allowlisted files. +- **Renderer.** `RuntimeCommand.rlimit_as_mb` is a row's own `RLIMIT_AS` in place of + the configured one; it refuses to exist unless `rlimit_as` is on and the size is + positive, and a configured limit of 0 still switches the limit off for every row. + The letsplot row sets 2048 MiB, `ARROW_DEFAULT_MEMORY_POOL=system`, + `FONTCONFIG_FILE=/opt/anyplot/fonts.conf` and `render_p95_s` 15. The pygal row keeps + the Python defaults (its heaviest catalogue render, bifurcation-basic, peaks at + 682 MB resident; the guarded wall p95 is 3.75 s). `executor.sandbox_env` passes an + in-process row's environment and own limit after the matplotlib keys and before + `PATH`, `HOME` and the limit variables, which a row can no longer replace, and + `PythonRuntime.env` gives the local Docker backend the same. The image installs + `lib-pygal`, `lib-letsplot` and `libcairo2` (without recommends), links DejaVu Sans + next to Serif and Mono, and copies `agents/renderer/fonts.conf` to + `/opt/anyplot/fonts.conf`: 1,174,874,816 bytes, 49.2 MB (4.4 %) more than 1b's. + `ci-image.yml` renders a pygal plot in both save forms (`render_to_png` at 3200x1800, + and a 4800x2700 chart through `svg2png` at 3200x1800, the canvas rewrite's form) and + a lets-plot plot through the harness in the image; the lets-plot step takes its + environment and limit from the command table's row and also fails on "Fontconfig + error" in stderr. The image build itself fails when any of 39 programs (those the + bridge's ImageMagick delegates to, such as `gs`, `curl`, `soffice` and `ffmpeg`, + ImageMagick's own tools, `fc-list`, `fc-match`, `java` and `node`; + `agents/renderer/delegates.py`) is on the sandbox's `PATH` (`executor.SANDBOX_PATH`), + so the Cloud Build deploy enforces it as well as the CI job, which runs the check once + more in the built image (the Copilot review of #12405: a CI-only check would let a + base-image change reach production unseen). +- **Host gates.** R2's blank check refuses a canvas that is 99.7 % one colour, not 98 %, + for every library (see the table below). +- **Evals.** `eligibility.py` sweeps all six libraries. `make_fixtures.py` writes the + ten specs for pygal and lets-plot (360 generated cases in all, 367 in a full run); the + smoke set gains `scatter-basic-pygal-renamed` and `scatter-basic-letsplot-renamed` + (16 cases). pygal has no column syntax, so its `renamed` case tests the axis titles + the adaptation must pass itself. The readiness scan blocks two of the ten pygal + fixture files, which `test_make_fixtures.py` pins: `histogram-basic` composites an + annotation layer onto the PNG with PIL (`io`, `PIL.Image`, `ImageDraw`, + `ImageFont`), and `line-timeseries` draws its random walk with the standard `random` + module, which no plot profile admits and none will: the adapter must not fabricate + data, which the ADAPTATION rules enforce. Their 12 cases are written anyway and open + blocked sessions until the catalogue files change; every lets-plot fixture file is + eligible. +- **Verified by hand.** The literal `ci-image.yml` steps against the 1c image: the + seaborn, plotnine and altair PNGs, the altair HTML and the multi-script render are + byte-identical to the 1b image's; pygal renders in 1.6 to 1.8 s at 185 to 189 MB, and + lets-plot in 5.0 to 5.3 s at 663 to 667 MB with an empty stderr. Through the local + Docker backend, the normalised `scatter-basic` of pygal (1.4 s per theme) and of + lets-plot (6.0 s per theme) render both themes at 3200x1800, and pygal's + `line-basic`, a 4800x2700 chart, renders at 3200x1800. `test_harness_guard.py` + renders the same three files under the guard where the libraries are installed. After + the review fixes, every renderer-image step of `ci-image.yml` passed three times + against the rebuilt image with the round-one PNG sizes (the pygal `svg2png` form + 3200x1800 too), and the normalised hand-built-SVG files (dendrogram-radial, + histogram-2d, upset-basic, ternary-density), heatmap-basic and lets-plot's + mosaic-categorical render at their target in both themes, which R2 accepts. + +#### Decisions that change or settle the text above + +| Text above | Built | Why | +|---|---|---| +| The canvas rewrite stays matplotlib's (1b) | Canvas rewrites for both libraries. pygal: a chart at another literal size with the target's aspect (in the catalogue only 4800x2700 and 3600x3600) saves through `cairosvg.svg2png(bytestring=chart.render(), write_to=f"plot-{THEME}.png", output_width=3200, output_height=1800)` or 2400 by 2400, the chart itself unchanged. lets-plot: only `scale=` on the PNG save changes (the PNG is the `ggsize` times the scale, floored) | R2's pixel cap (`MAX_IMAGE_PIXELS`, 11.52 MP) refuses a 4800x2700 or 3600x3600 PNG outright, and R2 is blocking: 123 of the 322 lets-plot files that render (108 of the 284 eligible) and about a third of the pygal files are that size, and lets-plot's scale sits in the protected save, out of the adapter's reach. After the rewrite 268 of 273 lets-plot renders are on target and none is over R2's pixel cap, and the guarded wall p95 falls from 25.9 s to 12.5 s; `svg2png` gives pygal's exact canvas with the same layout | +| lets-plot's `shutil.move`/`rmtree` (24 catalogue files) is rewritten to `ggsave(..., path=".")`, the form the other 301 use | `path="."` is added to 62 saves in 31 files, one computed `path=` becomes `"."`, 25 epilogues are removed and one returned path is unwrapped; the save rule requires `path="."` on every `ggsave` (`SaveRule.directory`), and readiness reports its absence | 31 files save without `path=`, not 24, and 292 pass `path="."`, not 301: 21 files move their outputs afterwards, 4 pass `path="."` and keep a dead `rmtree`, and others write straight into `./lets-plot-images` or compute the directory. Without `path=`, `ggsave` creates that directory, which the guard refuses (`os.mkdir`) | +| Not covered | The star import (`from lets_plot import *`) becomes one explicit import of the names the file uses, from the static list in `letsplot_names.py` | 151 files use it, not the survey's 158 (counted by AST and by grep). The validator refuses star imports; the expansion alone unlocks 132 files. A static list keeps the normaliser from importing lets-plot (the agents image does not install it, and an import runs its side effects); a test fails an upgrade that changes `__all__` | +| `render_to_png` and `ggsave` as save statements | pygal's rule also lists `render_to_file` (the HTML twin, `filename` only) and `cairosvg.svg2png` with a keyword-only `write_to=` and without `url=`, `file_obj=` and `unsafe=`; `render_to_png` takes only `filename` and `dpi` | 87 catalogue files end on `svg2png`. libcairo writes the PNG with its own `fopen`, which the audit hook never sees, so the static pin is the only thing that holds the path. `render_to_png` and `render_to_file` hand their `**kwargs` to the chart's config (`css=`, `js=`, `defs=`), and `svg2png`'s `url=` and `file_obj=` read through cairosvg's unrestricted fetcher | +| `pygal`, `cairosvg` and `cairocffi` in the import surface | `pygal` and `cairosvg` only; `cairocffi`, `cssselect2`, `tinycss2` and `webencodings` are unreachable. `xml.etree` and `Graph` subclasses stay out of pygal's profile, and PIL out of both | cffi's library loads and calls into libcairo raise no audit event. A forged token handed to cssselect2's selector compiler is the one route to an unquoted slot in the lambda it builds, so the CSS stack stays out of reach. `xml.etree`, class definitions and PIL change the security surface and wait for the owner ("Next"): `xml.etree` and `Graph` subclasses block 45 pygal files, PIL 4 lets-plot files | +| Not covered | `CODE_BUILDERS` gains cssselect2's `CompiledSelector.__init__` | Every pygal PNG export compiles the chart's stylesheet into one `eval("lambda el: ...")` per selector. The spike fed 339 selectors with quotes, escapes, newlines and Python text through every pygal channel: the generated source only compares `el`'s name, classes, id, attributes and `lang` | +| Not covered (pygal's HTML writes) | html-open: a write of exactly the chart's `render()` output to `f"plot-{THEME}.html"` becomes `chart.render_to_file(f"plot-{THEME}.html")` (158 files). html-drop: every other module-level HTML or SVG write (hand-built pages, post-processed SVG, `.svg` files) is removed with the assignments that only fed it (103 files) | Dropping alone would unlock the same files; the rewrite is chosen for the export: its HTML and PNG are byte-identical to the catalogue file's on all 156 runnable files (apart from pygal's generation-date comment), and `render_to_file` is a save name the validator pins while `open` stays banned. The dropped writes have no pinnable form, and the guard refuses `.svg` writes anyway; the export loses those pages | +| Not covered (Python keeps the default limits) | lets-plot's row has its own `RLIMIT_AS` of 2048 MiB (`RuntimeCommand.rlimit_as_mb`) with Arrow's system allocator and a minimal fontconfig file in its environment; not the Python default and not off as for altair | lets-plot rasterises in its Kotlin/Native bridge with ImageMagick linked in. An allocation it cannot make kills the process ("Out of memory trying to allocate"): SIGABRT (exit 134) on a developer machine, SIGSEGV (exit 139) in the renderer image, with either Arrow pool. pyarrow's default pool reserves another 1 GiB arena on the first pandas string column, which kills even a minimal 3200x1800 chart at 1024 and 1536 MiB; with the system allocator 18 of the 42 spike renders still die at 1024 MiB. With it, 2048 MiB held all 42 spike renders up to 4800x2700 and 3600x3600 (peak 1697 MiB) and still stops a 6400x3600 export (2580 MiB). No catalogue file renders at 6400x3600: the `ggsize(1600, 900)` files use `scale=3`, so the survey's worst case does not occur. Off, a runaway scale would meet only the `MemAvailable` watchdog | +| The slim image needs `libcairo2` | As planned, without recommends (a 13.8 MB layer), plus DejaVu Sans linked next to Serif and Mono and `/opt/anyplot/fonts.conf` | lets-plot draws its default text in DejaVu Sans and falls back to a Noto Sans face without it; its bridge's own fontconfig finds no configuration in the image and writes "Fontconfig error" on every export. `libcairo2` brings Debian's DejaVu faces and `/etc/fonts`, which left every seaborn, plotnine and altair render of `ci-image.yml` byte-identical | +| Not covered | In every profile a save name imported under another name, anything imported under a save name, and a bare save name that is not a call's callee are a `banned-call`; a `match` capture counts as a binding of a plotnine expression name | Both routes passed the shipped plotnine profile with no finding: `from plotnine import ggsave as keep` (or `g = ggsave`, `[ggsave][0]`, `functools.partial(ggsave, plot)`) reaches any path in the exported code, and `COLUMN = "x"` followed by `match '': case COLUMN: pass` hands plotnine code to evaluate in `aes(x=COLUMN)`. The normaliser renames the `export_ggsave` alias in 17 lets-plot files; no eligible file is lost, and the raw findings of the four shipped libraries are unchanged | +| Not covered (`.format` is a banned attribute) | lets-plot's profile exempts a `.format` with string-literal arguments (positional, `field=`, `format=`) at the end of a chain of builder methods (`LETSPLOT_BUILDER_METHODS`, each checked to return the builder in 4.11.0) whose root is `layer_tooltips()`, `layer_labels()` or `smooth_labels()`, imported from `lets_plot` under a name bound once whose attributes are never stored | 59 catalogue files use it 128 times, and the exemption is worth 53 eligible files (284 instead of 231). `str.format` stays refused, and so do a builder held in a variable, a rebound builder name, a non-literal argument and `lets_plot.layer_tooltips = str` | +| Not covered (R2's blank check refuses a canvas 98 % one colour, since phase 1) | `MAX_BACKGROUND_RATIO` is 0.997 for every library: R2 refuses a canvas that is 99.7 % one colour, and its message gives the share to one decimal | Measured on 2026-10-26 with `core.canvas.dominant_color`: the sparsest eligible pygal render (network-directed) is 99.44 % one colour, 33 of the 195 eligible pygal renders are at 98 % or more, and lets-plot's point-basic is at 98.38 %, so 0.98 refused about one pygal plot in six on every render. A pygal chart with only a title is 99.78 % and a bare pygal canvas 99.93 %. A pygal chart with axes and guides but no data is 98.62 % and passes; R2 never caught empty axes, because matplotlib's stay under 98 % too. A higher threshold refuses nothing that 0.98 accepted | +| `render_to_png` and `ggsave` as save statements (pygal's three save names, the row above) | `SaveRule.writes` gives each pygal save name the one file type it writes: `render_to_png` and `svg2png` the PNG, `render_to_file` the HTML. The validator reports a mismatch as `savefig-target` (`render_to_file writes only f"plot-{THEME}.html"`), and a PNG save under the wrong name does not count as the PNG save; the readiness scan reports it through `SaveRule.writes_suffix`, and the canvas step and `measure_canvas` leave such a save alone | The rule took any of the three names with either target, so `chart.render_to_file(f"plot-{THEME}.png")` wrote SVG text into the PNG and `render_to_png(f"plot-{THEME}.html")` a PNG into the HTML, both with no finding, and the canvas rewrite turned the first into `svg2png` (correctness review of 2026-10-26). No catalogue file changes | +| Not covered (`THEME` is a protected region, not a pinned name) | The SECURITY profile of every library pins `THEME` (rule `savefig-target`): its one binding is the first module-level theme block that `regions.is_theme_assignment` accepts, after `import os`. Every other binding is a finding at its own line: a second, augmented or annotated assignment, an unpacking, a `for`, `with`, `except`, `match`, walrus or comprehension target, an import, a `def` or parameter, `del`, a type alias and a `global` or `nonlocal` declaration. Once `import os` binds `os`, any other binding of `os` is an `os-use` finding | Every save target is `f"plot-{THEME}..."`, so a rebound `THEME` moves the write. The security review of 2026-10-26 passed the validator with `THEME` reassigned to a traversal literal before `svg2png`, whose native write the guard never sees; it failed only because no `plot-` directory existed. The pin refuses three theme blocks the validator passed (`os.environ.get`, an `os` alias, `.lower()`), which the readiness scan already blocked as `no-theme`. No catalogue file binds `THEME` twice, eligibility is unchanged, and only heatmap-polar (pygal, already blocked) gains a finding. The block's default value stays unpinned ("Next") | +| html-drop removes the HTML and SVG writes with the assignments that only fed them (the HTML row above) | html-drop also removes a module-level `with open(...)` of the theme's `.html` or `.svg` target whose body assigns names that nothing outside it reads and whose values hold no save call, as long as it writes at least once; html-open still rewrites only a single-write body | heatmap-basic writes its hand-built page from a two-statement body (`pygal_svg = chart.render(...)`, then `fout.write(...)`), which left `open` and a 216-character literal in the run form and blocked the file and its six fixture cases. The extension unlocks exactly that file (blocked to coupled); no other catalogue file's output changes | +| The pygal canvas rewrite reads the chart's literal size (the first row) | Where the `svg2png` bytestring traces to no chart, the normaliser reads the root ``'s `width=` and `height=` from the string literals that build the bytestring (through names, `+=`, `.append`, `.extend` and `.insert`), as numbers, `px` lengths or module-level names bound once, and sets the output size as for a traced chart. Several `` tags, a computed or `%` size, or a name bound twice leave a note. A size set after the constructor (`.width` or `.height` stores, `chart.config(...)`, `config=`, a positional `pygal.Config`, a `render()` keyword) makes the size unknown: a note, no rewrite, and no fallback to pygal's 800x600 | Four eligible pygal files (dendrogram-radial, histogram-2d, upset-basic, ternary-density) pass a hand-built 4800x2700 or 3600x3600 SVG to `svg2png` with no output size, so R2's pixel cap refused every render while the readiness reason promised R3's padding. They now render at 3200x1800 or 2400x2400 in both themes, and heatmap-adjacency gets the same rewrite but stays blocked by a function-level guard. Module-level size names turned 6 of the 7 unknown-size notes on eligible pygal files into known on-target sizes; heatmap-stripes-climate (`**_base`) keeps its note. No eligible pygal or lets-plot file now has a known size over the cap | + +#### Eligibility after 1c + +`uv run --extra agents python -m agents.evals.eligibility` on 2026-10-26; matplotlib +and seaborn are unchanged against main. plotnine and altair gain seven files from 1c's +library-agnostic rules: plotnine's bar-horizontal and spiral-timeseries and altair's +chessboard-basic, voronoi-basic, waffle-basic and scatter-ashby-material through the +guard rules, and plotnine's candlestick-volume through the `f"{d}/plot-{THEME}.png"` +target form. The review fixes moved one file: html-drop's extension makes pygal's +heatmap-basic eligible (208 before). The `THEME` pin and the save-name pairing change +no status. + +| Library | Files | Eligible | Clean | Coupled | Blocked | +|---|---|---|---|---|---| +| matplotlib | 325 | 285 (87.7 %) | 15 | 270 | 40 | +| seaborn | 325 | 287 (88.3 %) | 18 | 269 | 38 | +| plotnine | 321 | 267 (83.2 %) | 59 | 208 | 54 | +| altair | 325 | 273 (84.0 %) | 58 | 215 | 52 | +| pygal | 325 | 209 (64.3 %) | 64 | 145 | 116 | +| lets-plot | 325 | 284 (87.4 %) | 53 | 231 | 41 | + +Why the pygal and lets-plot files are blocked, first cause per file (each column sums to +its blocked count): + +| Cause | pygal | lets-plot | +|---|---|---| +| One of the 13 map specs | 13 | 13 | +| `xml.etree` post-processing of the SVG | 23 | 0 | +| A subclass of pygal's `Graph` (a class definition) | 18 | 0 | +| Old-form file: no `THEME` block and a single-theme `plot.png` | 13 | 7 | +| `io` or PIL compositing of the PNG | 12 | 4 | +| `sklearn.datasets` for the example data | 6 | 10 | +| `.format` on a string (lets-plot: hex colours) | 6 | 4 | +| matplotlib, outside pygal's surface | 4 | 0 | +| A guard shape the normaliser does not strip | 3 | 0 | +| A string literal over 200 characters | 3 | 1 | +| `.query` | 0 | 1 | +| `qrcode` and a URL literal | 0 | 1 | +| Other one-offs | 15 | 0 | + +By SECURITY rule (a file can have several): pygal `banned-import` 62, `savefig-target` +30, `banned-statement` 22, `dunder` 22, `banned-keyword` 19, `banned-attribute` 16, +`string-length` 12, `banned-call` 7, `os-use` 6, `banned-name` 5, `url-literal` 2; +lets-plot `banned-import` 18, `banned-call` 11, `savefig-target` 8, `banned-attribute` +7, `url-literal` 1, `string-length` 1. 216 normalised pygal files and 293 lets-plot +files pass SECURITY; the map specs and the files without a `THEME` block make up the +rest of the gap. The static count includes files that fail their first render anyway: +13 pygal and 10 lets-plot files divide by the length of the emptied title, +waveform-audio (pygal) runs past `RLIMIT_CPU` in cairosvg's path parser, and two +lets-plot files do not render with lets-plot 4.11.0: calibration-curve raises a +MagickWand error in the export, and line-confidence writes an almost fully transparent +PNG, which R2 refuses as 100 % one colour. R2's blank check, at 99.7 %, passes every +eligible pygal render measured (the sparsest is 99.44 %). + +#### Next + +Settled by 1c: normaliser rules for the guard shapes of 1b's third blocked row. The four +library-agnostic rules and the new target form took plotnine from 264 to 267 and altair +from 269 to 273. The rest of that row stays, altair's two `"site-packages" in X.origin` +loaders among it, which the find-spec rule leaves alone. Also settled: the fixtures and +smoke cases for pygal and lets-plot ("What 1c built"), and the review of the lets-plot +bridge (the owner questions below). What stays from 1b's list, and what 1c adds: + +- plotnine's own canvas rewrite through `save(dpi=, width=, height=)` and `ggsave`. + plotnine and altair are now the only Python libraries whose canvas rests on the R3 + gate. +- The readiness coupling scan knows only matplotlib's limit and tick calls: plotnine's + `coord_cartesian(xlim=, ylim=)` and `scale_*_continuous(limits=, breaks=)`, altair's + `alt.Scale(domain=)` and axis `values=`, pygal's `range=` (111 files) and `xrange=` + (75 files), and lets-plot's literal `limits=` and `breaks=` (126 files, of which the + scan flags 10) go unflagged, so the adapter gets no `limits` hint for them. +- altair, pygal and lets-plot write no probe, so the advisory gates (G3 to G12, the + row-fidelity check G8 among them) never run for them; whether a row count read from + the chart's own spec should replace G8 is open. +- The normaliser blanks the catalogue title to `""`; code that divides by `len(title)` + (11 plotnine, 5 altair and 54 matplotlib files, and among the eligible files 13 pygal + and 10 lets-plot ones) fails a raw render with ZeroDivisionError unless the adapter + writes a new title. A non-empty placeholder would avoid it. +- HTML saves stay in the run form, uncollected: altair's `disable_max_rows()` heatmap + writes 50 MB and can hit `RLIMIT_FSIZE`, and pygal's and lets-plot's reach 25 MB + (lets-plot's heatmap-mandelbrot 25.3 MB, about 40 % of the 64 MiB run budget; pygal's + ice-basic 21.7 MB). The normaliser could drop the HTML save after all. +- `matrix.py` warns when `AGENT_LIBRARIES` does not enable a selected case's library; + whether the harness should enable the cases' libraries itself is open. +- Arrow's system allocator for every plot row: pyarrow's default pool reserves a 1 GiB + address-space arena on the first pandas string column, which counts against + `RLIMIT_AS` in every Python row, and only lets-plot's row and the conversions avoid + it. The CI lets-plot step takes its variables from the command table's row but cannot + tell whether the allocator took effect, because its minimal chart passes at 2048 MiB + with either pool; only unit tests pin it. A second run at 1536 MiB would tell (the + system pool exits 0, the default pool 139). +- A resource-error grammar for lets-plot: under `RLIMIT_AS` the bridge dies by a signal + after "Out of memory trying to allocate" (SIGABRT, exit 134, on a developer machine; + SIGSEGV, exit 139, in the renderer image) or raises a MagickWand `ValueError` from the + export, and `gates.error_summary` hands that `ValueError` to the repair as a code + error on the `ggsave` line. All three should read as resource errors. +- Three eligible files always fail R2's pixel cap: matplotlib's dot-matrix-proportional + and timeseries-decomposition and seaborn's heatmap-clustered crop a 4800x3000 or + 4800x3600 canvas with `bbox_inches="tight"` at an aspect that no dpi brings onto a + target. Their readiness reason says so since 2026-10-26. A fit inside the target, as + lets-plot's rewrite does, or blocking a known over-cap canvas in the readiness scan + would change what matplotlib and seaborn do today. +- pygal's cairo writes a fontconfig cache of about 184 KB (7 files) into + `$HOME/.cache/fontconfig`, the sandbox's private `/tmp`, on every render: native + writes the guard cannot see, inside the sandbox boundary and at no measurable time. + Accept it, or bake a cache, which needs Debian's `fontconfig` package (`fc-cache`) in + the image. +- Validator questions from the build: no rule refuses an attribute store on an imported + module (`np.x = ...`; closed only where the builder exemption depends on it); the + SECURITY profile bans a private attribute only on a receiver that resolves through an + import (`chart.config._update(...)` passed until `_update` was banned by name in + pygal's profile; the DATA profile bans every private attribute); and the URL rule + does not match `javascript:` URLs, which pygal's value metadata (`xlink` hrefs) and + altair's href channel can put into the exported HTML (the sandbox is not affected). + The `THEME` pin leaves the theme block's default unpinned: + `regions.is_theme_assignment` accepts any second argument to `os.getenv`. The + renderer always sets `ANYPLOT_THEME`, so the default never reaches a render, but an + exported file run without the variable names its files from it. +- Owner questions: + - The 80 % matrix gate for pygal: 12 of its 60 generated cases (the `histogram-basic` + and `line-timeseries` files) open blocked sessions, so pygal reaches at most 48 of + 60, exactly the gate, and only if every eligible case passes. Count only the + eligible pairs in the gate, regenerate the two catalogue files through the pipeline + (the annotation drawn by pygal instead of PIL, `np.random.default_rng` instead of + `random`), or accept it. + - pygal's `xml.etree` post-processing (26 files) and `Graph` subclasses (23 files), + 45 blocked files as the groundwork counted the first cause. Admitting `xml.etree` + with a ban list on its readers and writers would unlock 14; a class definition is + refused in every profile today. + - PIL for lets-plot: 4 files post-process the PNG with PIL (chord-basic, + heatmap-chromagram, rose-basic, windrose-basic); altair's reopen rule could extend + to them. + - CJK and Arabic text in pygal: cairo's toy font API resolves one face per text run + and never falls back per glyph, and cairosvg takes only the first family of + `font-family`, so such text in user data draws as DejaVu's empty boxes; the + harness's fallback families and G10 are matplotlib's. Accept it, or have the + adapter put `Noto Sans CJK JP` or `Noto Sans` first when the data's script needs it. + - The lets-plot native bridge before enabling: it imports `execvp`, `fork`, `popen`, + `system` and `mkstemp` (ImageMagick's delegates and temp files), which the audit + hook cannot see. The adversarial security review of 2026-10-26 (real renders under + the harness with `strace -f`) found no escape and no validator bypass with an effect: + titles, labels and font families with ImageMagick coder prefixes (`@`, `caption:`, + `label:`, `msl:`) never reach a coder, and `geom_imshow` builds an in-memory + `data:` href from an array and reads no file, so admitted code cannot steer the + native layer. The bridge's native process starts are bounded by the image's `PATH`, + `RLIMIT_NPROC` and the sandbox, and since 2026-10-26 every build of the renderer + image, the Cloud Build deploy included, fails when any of 39 delegate programs is on + the sandbox's `PATH` (`agents/renderer/delegates.py`). No ImageMagick policy ships: + the bridge's ImageMagick is a zero-configuration build that reads no `policy.xml` + and no `MAGICK_*` variable (a policy denying every coder and delegate changed no + export, on the host or in the image). The two `fc-list` starts in the review's + traces come from matplotlib's font manager, not the bridge, whose plain export + started no process in a host trace; the image has no `fc-list`. Accept this containment, or add a + seccomp filter that denies `execve` in the render container before lets-plot is + enabled. +- The owner's matrix run per library on Vertex (question 7), then `_LIBRARIES` in + `agents/cloudbuild.yaml`. + ### 2. R: ggplot2 (the exec-mode pilot) - Toolchain: R 4.6.1 as in `.github/actions/setup-r` (Posit snapshot 2026-10-20, the same @@ -614,7 +920,7 @@ this plan: | `save` is banned as an attribute and a name, so altair's `chart.save`, plotnine's `plot.save` and bokeh's `save(p)` fail SECURITY today; altair's PIL block, the `plot-.html` saves and plotly's `include_plotlyjs='cdn'` need rules | Step 1 split into 1a (the seam, no behaviour change) and one PR per library pair, each with the eligibility sweep's numbers | | `RLIMIT_NPROC` counts threads; headless Chromium plus Node is past 64 | Measured: 77 tasks; `RLIMIT_NPROC` off for the JavaScript row | | Missed sites: `evals/matrix.py` preflight, `stress.py`, `ladder_lab.py`, the `pipeline.PythonRuntime` monkeypatch in `test_service_flow.py`; backends hold one runtime; `RENDER_P95_S` is the repair reserve | All in step 1a; backends resolve the runtime per job; the p95 is a runtime attribute | -| Image: pin the GHCR base by digest; Highcharts in a public image is a redistribution question; bake `fc-cache`, `XDG_CACHE_HOME=/tmp`; Julia offline flags; `libcairo2` for cairosvg; the `ci-image.yml` change filter | Steps 1c, 2, 5 and question 11 | +| Image: pin the base image by digest; Highcharts in a public image is a redistribution question; bake `fc-cache`, `XDG_CACHE_HOME=/tmp`; Julia offline flags; `libcairo2` for cairosvg; the `ci-image.yml` change filter | Steps 1c, 2, 5 and questions 1 and 11 (the base image is private, so it redistributes nothing) | | `render.mjs` takes the library from the file stem; MUI X imports are inlined by esbuild | `--library=` from the request against the closed set; a static-import allowlist for `.tsx` | | Deny lists are hygiene, not a boundary: `globalThis["fe"+"tch"]`, `[].constructor.constructor`, R `get("system")`, Julia `getfield(Base, Symbol(...))` pass any identifier list | The threat model is stated: the sandbox protects the service, the list protects the person who runs the export from accidental I/O by the model; the lists grew by the review's names, and Julia's `using`/`import` is an allowlist | | Order: R first as the smallest exec-mode pilot, then JavaScript, then plotly and bokeh on the proven Chromium path, then Julia | Adopted (question 3) | @@ -628,10 +934,20 @@ cold start under gVisor. Defaults apply until answered; each is marked where the plan uses it. 1. **Image layout.** (A) one `agents/renderer/Dockerfile` that builds every toolchain - (an hour-long build on each renderer change, a 20-minute CI job today), or (B) a - public toolchain base image on GHCR built by a workflow on lock-file changes, with - the renderer image `FROM` it (fast CI and deploys; everything in it is open source). - Default: B. + (fast only with a layer cache in CI and in Cloud Build, which builds the renderer + without one today, and with the layers ordered by how often they change; a cold + cache rebuilds every toolchain, and Julia's precompile alone took 18 minutes in the + spike), or (B) a toolchain base image built on lock-file changes, with the renderer + image `FROM` it by digest (fast CI and deploys). + **Decided (owner, 2026-10-25): B, with the base image private in Artifact Registry** + (the repository `anyplot` in `europe-west4`, where the renderer image already lives). + A Cloud Build trigger builds and pushes it with Cloud Build's own service account, and + the renderer's Cloud Build deploy pulls it the same way. The CI job `renderer-image` in + `ci-image.yml` pulls it through the Workload Identity Federation login that + `ci-tests.yml` already uses, which needs the Artifact Registry reader role on the + repository: an owner task when step 2 adds the base image. Not public on GHCR: a + public image is a redistribution (licence notices for everything in it, no + Highcharts), and its only gain was pulls without a credential. 2. **Base distribution.** R's binaries from Posit exist for Ubuntu, not Debian, so the toolchain image is Ubuntu 24.04 (rocker's R layout) with Python 3.14 managed by uv instead of `python:3.14-slim`. Default: yes. @@ -663,7 +979,7 @@ Defaults apply until answered; each is marked where the plan uses it. byte for byte the run form (agent-network.md, "Fencing and exported code"). Default: accept the deviation for these two libraries and document it there. 11. **Highcharts in a public toolchain image**: the free licence covers non-commercial - use, and a public GHCR image redistributes the bundle. Default: the image stays - private in Artifact Registry until decided (CI then needs a reader credential), or - Highcharts is installed in the renderer image's own layer from the lock file at - build time rather than shipped in the base. + use, and a public GHCR image redistributes the bundle. **Settled by question 1 + (2026-10-25):** the base image is private in Artifact Registry, so it redistributes + nothing, and Highcharts can live in it. Whether users' plots may use Highcharts stays + question 6. diff --git a/agents/README.md b/agents/README.md index 98f8ba89a72..0a03eba148e 100644 --- a/agents/README.md +++ b/agents/README.md @@ -4,7 +4,7 @@ This directory holds the anyplot agent network: the service that lets an admin p ## What is built -The runtime core runs locally: the agents, the plot pipeline, the guardrail plugins, the render layer, the private `/v1` service with its run queue, the theme toggle, and the footer strip on every PNG the service serves ("made with any.plot()" and "anyplot.ai/", see [Footer strip](../docs/concepts/agent-network.md#footer-strip)). The renderer service `anyplot-renderer` (`renderer/`), which runs the adapted code in Cloud Run sandboxes, and the `remote` backend that calls it are built, and the renderer has run in Cloud Run since 2026-10-11 (deployed by the `deploy-renderer` trigger; see [Render through the deployed renderer](#render-through-the-deployed-renderer)). The model-regression harness, the 240 synthetic cases (the 120 of spike X for matplotlib and seaborn, and since 2026-10-25 the same for plotnine and altair), the 23 stress cases with their model-free static stage, the blind two-run review gallery and the catalogue eligibility sweep are built (see [Run the regression harness](#run-the-regression-harness)), and the Claude Haiku 5.5 baseline from the thinking run of 2026-10-11 on main is committed (`evals/baselines/claude-haiku-5-5.json`: 122 runs, all passed the gates, 50.8 % ended `ok`); the Gemini baseline is not. The chat page that uses the service through the API's `/debug/agent` routes is in the app (`app/src/pages/AgentChatPage.tsx`, built with `VITE_ENABLE_AGENT_CHAT=true`); since 2026-10-11 its data field also takes free text ("Hund 7 Katze 3 und Vogel 4"), which the judge model reads as a table that the user confirms before it is stored (`anyplot/data/transcribe.py`, rubric `prompts/transcriber.md`), and the flow has two steps: the user confirms the data with its bindings (`POST /v1/sessions/{sid}/dataset/confirm`) before the pipeline draws anything, and in step 1 the root's `prepare_data` tool changes the data on request from a fixed set of operations the server applies (`anyplot/data/prepare.py`, `anyplot/dataset_versions.py`), with the `done` event carrying the new version, the `summarize_data` action for the step-1 summary and `POST /v1/sessions/{sid}/dataset/undo` as the user's undo (since 2026-10-14 the lines pasted above or below a table, which the parser leaves out of it, are kept as the user's notes about the data: the dataset route runs the pre-filter and the scope judge on them, returns the kept ones as `context_lines`, and the `summarize_data` turn and `get_dataset_profile` hand them to the root fenced as ``, see [Request flow](../docs/concepts/agent-network.md#request-flow-for-use-with-my-data)), and its `generate_data` tool computes the data from a formula the model chose, such as `sin(x)` from -pi to pi (`anyplot/data/generate.py`; a formula pasted into the data field answers `422 formula`) (see [Two steps: data first, then the plot](../docs/concepts/agent-network.md#two-steps-data-first-then-the-plot)); the prep eval scores the root's choices (see [Run the prep eval](#run-the-prep-eval)). Since 2026-10-12 a session has one language for the replies and every text in the plot, English by default whatever the browser's locale, set since 2026-10-14 by a paste before any typed message from the language of its notes or column names (`pasted`), by the first in-scope typed message (`detected`), or for good by the chip in the chat header (`PUT /v1/sessions/{sid}/language`) or the root's `set_language` tool (`chosen`); code, comments, column names and labels from the data stay as they are (`anyplot/language.py`, see [Session language](../docs/concepts/agent-network.md#session-language)). The service's own image (`Dockerfile`) and Cloud Build config (`cloudbuild.yaml`) are built, with a CI job that builds and smokes the image before merge, and the service has run in Cloud Run since 2026-10-11, deployed by the `deploy-agents` trigger (see [Build and deploy the service](#build-and-deploy-the-service)); the API side is switched on for admins since 2026-10-11 (the user-id secret is bound, the API trigger sets the service URL and `_AGENT_ENABLED=true`). The judge-only scope eval and its cases are built, and its first full run on 2026-10-10 passed both gates (see [Run the scope eval](#run-the-scope-eval)). Real sessions reach the harness too: `evals/import_case.py` turns a saved session or a stored feedback case into an eval case, and since 2026-10-12 `evals/sync_cases.py` pulls the cases you promoted on the debug page from the private case bucket (see [Sync promoted cases from the bucket](#sync-promoted-cases-from-the-bucket)). Not built yet: the scope cases' second stage through the root, and the `agents-eval.yml` workflow. +The runtime core runs locally: the agents, the plot pipeline, the guardrail plugins, the render layer, the private `/v1` service with its run queue, the theme toggle, and the footer strip on every PNG the service serves ("made with any.plot()" and "anyplot.ai/", see [Footer strip](../docs/concepts/agent-network.md#footer-strip)). The renderer service `anyplot-renderer` (`renderer/`), which runs the adapted code in Cloud Run sandboxes, and the `remote` backend that calls it are built, and the renderer has run in Cloud Run since 2026-10-11 (deployed by the `deploy-renderer` trigger; see [Render through the deployed renderer](#render-through-the-deployed-renderer)). The model-regression harness, the 360 synthetic cases (the 120 of spike X for matplotlib and seaborn, the same for plotnine and altair since 2026-10-25, and for pygal and lets-plot since 2026-10-26), the 23 stress cases with their model-free static stage, the blind two-run review gallery and the catalogue eligibility sweep are built (see [Run the regression harness](#run-the-regression-harness)), and the Claude Haiku 5.5 baseline from the thinking run of 2026-10-11 on main is committed (`evals/baselines/claude-haiku-5-5.json`: 122 runs, all passed the gates, 50.8 % ended `ok`); the Gemini baseline is not. The chat page that uses the service through the API's `/debug/agent` routes is in the app (`app/src/pages/AgentChatPage.tsx`, built with `VITE_ENABLE_AGENT_CHAT=true`); since 2026-10-11 its data field also takes free text ("Hund 7 Katze 3 und Vogel 4"), which the judge model reads as a table that the user confirms before it is stored (`anyplot/data/transcribe.py`, rubric `prompts/transcriber.md`), and the flow has two steps: the user confirms the data with its bindings (`POST /v1/sessions/{sid}/dataset/confirm`) before the pipeline draws anything, and in step 1 the root's `prepare_data` tool changes the data on request from a fixed set of operations the server applies (`anyplot/data/prepare.py`, `anyplot/dataset_versions.py`), with the `done` event carrying the new version, the `summarize_data` action for the step-1 summary and `POST /v1/sessions/{sid}/dataset/undo` as the user's undo (since 2026-10-14 the lines pasted above or below a table, which the parser leaves out of it, are kept as the user's notes about the data: the dataset route runs the pre-filter and the scope judge on them, returns the kept ones as `context_lines`, and the `summarize_data` turn and `get_dataset_profile` hand them to the root fenced as ``, see [Request flow](../docs/concepts/agent-network.md#request-flow-for-use-with-my-data)), and its `generate_data` tool computes the data from a formula the model chose, such as `sin(x)` from -pi to pi (`anyplot/data/generate.py`; a formula pasted into the data field answers `422 formula`) (see [Two steps: data first, then the plot](../docs/concepts/agent-network.md#two-steps-data-first-then-the-plot)); the prep eval scores the root's choices (see [Run the prep eval](#run-the-prep-eval)). Since 2026-10-12 a session has one language for the replies and every text in the plot, English by default whatever the browser's locale, set since 2026-10-14 by a paste before any typed message from the language of its notes or column names (`pasted`), by the first in-scope typed message (`detected`), or for good by the chip in the chat header (`PUT /v1/sessions/{sid}/language`) or the root's `set_language` tool (`chosen`); code, comments, column names and labels from the data stay as they are (`anyplot/language.py`, see [Session language](../docs/concepts/agent-network.md#session-language)). The service's own image (`Dockerfile`) and Cloud Build config (`cloudbuild.yaml`) are built, with a CI job that builds and smokes the image before merge, and the service has run in Cloud Run since 2026-10-11, deployed by the `deploy-agents` trigger (see [Build and deploy the service](#build-and-deploy-the-service)); the API side is switched on for admins since 2026-10-11 (the user-id secret is bound, the API trigger sets the service URL and `_AGENT_ENABLED=true`). The judge-only scope eval and its cases are built, and its first full run on 2026-10-10 passed both gates (see [Run the scope eval](#run-the-scope-eval)). Real sessions reach the harness too: `evals/import_case.py` turns a saved session or a stored feedback case into an eval case, and since 2026-10-12 `evals/sync_cases.py` pulls the cases you promoted on the debug page from the private case bucket (see [Sync promoted cases from the bucket](#sync-promoted-cases-from-the-bucket)). Not built yet: the scope cases' second stage through the root, and the `agents-eval.yml` workflow. The model is **Claude Haiku 5.5 on Vertex AI** (`claude-haiku-5-5`) by default. **Gemini 3.8 Flash** is the second arm: set `AGENT_PROVIDER=gemini` together with Gemini model ids, so the two can be compared on price and quality later. Every agent and the scope judge run on the configured provider. @@ -31,7 +31,7 @@ The model is **Claude Haiku 5.5 on Vertex AI** (`claude-haiku-5-5`) by default. | `anyplot/plugins/` | `ScopeGuardPlugin`, `BudgetPlugin`, `ToolSafetyPlugin` and the request ledger they share | | `anyplot/output_guard.py` | The output sanitiser of every root message and plot-event line (links, HTML and long code blocks out, at most 1,200 characters) and the reply cap the ScopeGuard runs on the root's final reply before it is stored: a reply over 1,200 characters becomes the fixed refusal, the same in every language, or is cut at 1,200 characters when the turn ran the plot pipeline | | `anyplot/render/` | The render contract, the probe harness, the host gates (R1-R3, advisory G3/G5/G7/G8/G9/G10/G11/G12), PNG hardening, the render store, the `remote` (the renderer service, phase 1), `local`, `fake` and `sandbox` (an unused in-process stub) backends, `serial.py`, the one render semaphore in front of every backend, and `watermark.py`, the footer strip the artifact route and the feedback bundle add to the raw render | -| `anyplot/render/runtimes/` | The runtime registry (`runtime_for(library)`, `RUNTIME_LIBRARIES`): one `RuntimeAdapter` per library, which binds the language's code modules (normaliser, regions, readiness, validator, edits, loader, export), its error grammar and its render command for the pipeline, the theme toggle, the local backend and the eval harness; `python.py` serves matplotlib, seaborn, plotnine and altair (`PYTHON_LIBRARIES`; the last two since 2026-10-25), each with its own save rule (`anyplot/code/regions.py`, `SAVE_RULES`) and validator profile. A later language adds a runtime here, never a backend (`agentic/specs/261024-agent-all-libraries.md`) | +| `anyplot/render/runtimes/` | The runtime registry (`runtime_for(library)`, `RUNTIME_LIBRARIES`): one `RuntimeAdapter` per library, which binds the language's code modules (normaliser, regions, readiness, validator, edits, loader, export), its error grammar and its render command for the pipeline, the theme toggle, the local backend and the eval harness; `python.py` serves matplotlib, seaborn, plotnine, altair, pygal and lets-plot (`PYTHON_LIBRARIES`; plotnine and altair since 2026-10-25, pygal and `letsplot` since 2026-10-26), each with its own save rule (`anyplot/code/regions.py`, `SAVE_RULES`) and validator profile. A later language adds a runtime here, never a backend (`agentic/specs/261024-agent-all-libraries.md`) | | `anyplot/render/fonts/` | JetBrains Mono 2.304 Regular and Bold for the footer strip, unmodified, with `OFL.txt` (SIL Open Font License 1.1) and a notice with their SHA-256 values ([fonts README](anyplot/render/fonts/README.md)) | | `renderer/` | The `anyplot-renderer` service: `main.py` (`POST /render`, `GET /status`, one render slot), `executor.py` (one `sandbox do` per theme with the kill path, the byte watchdog and the launcher retry), `commands.py` (the command table: one fixed render command per language, selected by the job's `(language, library)` and never taken from the request; the harness's exec mode runs the non-Python commands; `GET /status` reports under `languages` which toolchains this image carries, and a job in a language without one answers `503 runtime_unavailable`), `auth.py` (the caller check), `settings.py` (`RENDERER_*`), `wire.py` (the JSON contract the `remote` backend shares), `Dockerfile` and `cloudbuild.yaml`. No ADK import and no `__init__.py`, so `adk web agents` does not list it as an agent | | `anyplot/opening.py`, `session_state.py`, `services.py`, `briefs.py` | Opening a session and taking in a dataset, the server-set session state, the process-wide stores, and the spec and dataset briefs | @@ -43,15 +43,15 @@ The model is **Claude Haiku 5.5 on Vertex AI** (`claude-haiku-5-5`) by default. | `evals/score.py`, `anyplot/scoring.py`, `anyplot/prompts/scorer.md` | Scores a harness run's renders with the catalogue rubric on Claude Opus: the measurement behind the quality ladder ([Score the renders with the catalogue rubric](#score-the-renders-with-the-catalogue-rubric)) | | `anyplot/cli_transport.py`, `scripts/agents-experiment.sh` | The `claude-cli` transport and the experiment runner: the live code with its Claude calls on your subscription ([Run an experiment on your Claude subscription](#run-an-experiment-on-your-claude-subscription)) | | `evals/ladder_lab.py` | Compares reviewers, feedback forms and repairers on the same seed renders through the local Claude Code CLI ([Compare reviewers, feedback forms and repairers](#compare-reviewers-feedback-forms-and-repairers-ladder-lab)) | -| `evals/make_fixtures.py` | The seeded generator of the 240 synthetic cases: 10 specs times matplotlib, seaborn, plotnine and altair times 6 datasets | +| `evals/make_fixtures.py` | The seeded generator of the 360 synthetic cases: 10 specs times matplotlib, seaborn, plotnine, altair, pygal and lets-plot times 6 datasets | | `evals/make_stress.py`, `evals/stress.py` | The seeded generator of the 23 stress cases, and the model-free static stage that `--static` runs over them ([Run the stress cases](#run-the-stress-cases)) | | `evals/cases.py`, `evals/pricing.py`, `evals/report.py` | Case loading and selection, list prices, and the summary, diff and gallery code; `report.py` also merges two runs into one blind gallery and scores its export | | `evals/import_case.py` | Turns one feedback case, a saved session bundle or a case directory copied from the bucket, into an eval case in the git-ignored `evals/.cases/` ([Import a feedback case](#import-a-feedback-case)) | | `evals/sync_cases.py` | Keeps `evals/.cases/` in line with the `agent_feedback` table: imports the promoted cases from the private case bucket, removes the retired ones, and with `--backfill` indexes cases that have no row ([Sync promoted cases from the bucket](#sync-promoted-cases-from-the-bucket)) | -| `evals/eligibility.py` | The model-free sweep of the catalogue's files of every Python library with a code profile (matplotlib, seaborn, plotnine, altair): eligible, coupled and blocked pairs, and why | +| `evals/eligibility.py` | The model-free sweep of the catalogue's files of every Python library with a code profile (matplotlib, seaborn, plotnine, altair, pygal, lets-plot): eligible, coupled and blocked pairs, and why | | `evals/scope.py`, `evals/scope.evalset.json` | The judge-only scope eval and its 219 synthetic cases: in-scope, off-topic and adversarial messages and notes pasted with a table, scored for adversarial recall and false refusals | | `evals/prep.py`, `evals/fixtures/prep/` | The live prep eval and its 51 German and English data requests (10 of them `hard`, 11 data-code cases): each through `/v1` in process with the real model, scored on the `prepare_data` operations and `generate_data` calls the root chose, and on the size of the table each `run_data_code` call stored (a `refuse` case passes when nothing was stored) ([Run the prep eval](#run-the-prep-eval)) | -| `evals/fixtures/cases/` | The eval cases: 240 generated (`--`), the hand-written `scatter-basic-matplotlib`, `bar-grouped-seaborn` and `scatter-basic-matplotlib-german-message` (a German follow-up that sets the session language), and 23 stress cases (`stress-`) ([fixtures README](evals/fixtures/README.md)) | +| `evals/fixtures/cases/` | The eval cases: 360 generated (`--`), seven hand-written, among them `scatter-basic-matplotlib`, `bar-grouped-seaborn` and `scatter-basic-matplotlib-german-message` (a German follow-up that sets the session language), and 23 stress cases (`stress-`) ([fixtures README](evals/fixtures/README.md)) | | `evals/baselines/` | Committed baseline reports, one per model ([baselines README](evals/baselines/README.md)) | The unit tests are in `tests/unit/agents/`; `tests/unit/agents/runtime/` drives a full "Create plot" through `/v1` with a fake renderer and scripted models for both providers, and `tests/unit/agents/evals/` runs the harness the same way. @@ -76,7 +76,7 @@ Three rules hold for everything here: | `AGENT_CLI_CONCURRENCY`, `AGENT_CLI_CALL_LOG` | `3`, unset | `claude-cli` only: CLI calls at the same time; a file that gets one JSON line per call | | `AGENT_LOCATION` | `eu` | Vertex AI location: `eu`, `us` or `global` | | `AGENT_PROJECT`, else `GOOGLE_CLOUD_PROJECT` | `anyplot` | Project that serves and bills the model calls | -| `AGENT_LIBRARIES` | `matplotlib,seaborn` | Enabled libraries; each needs a runtime in the registry (`anyplot/render/runtimes/`, `RUNTIME_LIBRARIES`: `matplotlib`, `seaborn`, `plotnine` and `altair`), others are refused at startup. The default and the deploy's `_LIBRARIES` stay `matplotlib,seaborn`; the owner enables plotnine and altair after each one's eval matrix reaches 80 % | +| `AGENT_LIBRARIES` | `matplotlib,seaborn` | Enabled libraries; each needs a runtime in the registry (`anyplot/render/runtimes/`, `RUNTIME_LIBRARIES`: `matplotlib`, `seaborn`, `plotnine`, `altair`, `pygal` and `letsplot`), others are refused at startup. The default and the deploy's `_LIBRARIES` stay `matplotlib,seaborn`; the owner enables plotnine, altair, pygal and lets-plot one by one, each after its eval matrix reaches 80 % | | `AGENT_RENDERER` | `remote` | `remote` (the renderer service), `local` (Docker, development only), `fake` (fixture PNGs, development and test only) or `sandbox` (an in-process stub, not used in phase 1) | | `AGENT_RENDER_URL` | unset | The renderer service URL; the `remote` backend refuses to start without it; `http://` only in development and test | | `AGENT_RENDER_TOKEN` | unset | Development only: an ID token for the renderer; without it the backend mints one (see [Render through the deployed renderer](#render-through-the-deployed-renderer)) | @@ -165,10 +165,10 @@ Claude runs with adaptive thinking on the root (`AGENT_ROOT_THINKING_BUDGET`, 1, ### Before you begin -1. Install the dependencies, including the four plotting libraries the renderer uses (the harness tests render plotnine and altair in process and skip them without their extras): +1. Install the dependencies, including the six plotting libraries the renderer uses. The harness tests render seaborn, plotnine, altair, pygal and lets-plot in process and skip each one without its extra. pygal's PNG export also needs the system's cairo library (`libcairo2` on Debian and Ubuntu, `cairo` in Homebrew). ```bash - uv sync --extra agents --extra test --extra lib-matplotlib --extra lib-seaborn --extra lib-plotnine --extra lib-altair + uv sync --extra agents --extra test --extra lib-matplotlib --extra lib-seaborn --extra lib-plotnine --extra lib-altair --extra lib-pygal --extra lib-letsplot ``` Never install `openai-agents` into this environment: it ships a top-level `agents` module that shadows this package. @@ -316,7 +316,7 @@ The renderer runs only on Cloud Run, because `sandbox do` exists only there. On ENVIRONMENT=development uv run uvicorn agents.renderer.main:app --port 8003 ``` -Its settings are `RENDERER_*` variables, documented in `renderer/settings.py`. The defaults are the deployed limits: a 64 MiB byte budget and 10,000 files per run, a 30-second wait for the one render slot, a 1,024 MiB `MemAvailable` floor before a render and a 512 MiB floor during one (below it the watchdog kills the sandbox), and the rlimits 60 s CPU, 50 MiB per file, 1 GiB address space and 64 processes. +Its settings are `RENDERER_*` variables, documented in `renderer/settings.py`. The defaults are the deployed limits: a 64 MiB byte budget and 10,000 files per run, a 30-second wait for the one render slot, a 1,024 MiB `MemAvailable` floor before a render and a 512 MiB floor during one (below it the watchdog kills the sandbox), and the rlimits 60 s CPU, 50 MiB per file, 1 GiB address space and 64 processes. A row of the command table (`renderer/commands.py`) can differ: altair's row runs without the address-space limit, and since 2026-10-26 lets-plot's row has its own 2048 MiB (`rlimit_as_mb`) with Arrow's system allocator and `FONTCONFIG_FILE` in its environment, because its native rasteriser aborts every 3200x1800 export under 1 GiB. `RENDERER_RLIMIT_AS_MB=0` still switches the limit off for every row. On Cloud Run the service also checks itself: it refuses every render with `503 volume_missing` when `/tmp/runs` is not a mount of its own of at most 1 GiB (the deploy's in-memory volume), and it ends its own process when a sandbox launcher outlives its kill, so Cloud Run starts a fresh instance instead of answering `503 stuck` until the instance goes idle. `GET /status` reports both. A caller that goes away, or a `POST /render/{job_id}/cancel`, stops the render in progress. `job_id` and the requested themes are an idempotency key: a replayed `POST /render` with the same payload joins the render in flight or gets the stored answer of a finished one, and another payload under the same key is refused with `409 job_conflict`, so the `remote` backend's retries never run a job's code twice. @@ -326,9 +326,9 @@ The image builds from the repository root: docker build -f agents/renderer/Dockerfile -t anyplot-renderer:dev . ``` -The image's `fonts` stage installs Debian's `fonts-noto-core` and `fonts-noto-cjk` and copies 37 allowlisted files (Noto Sans, Noto Sans CJK Regular and Bold, and the script families DejaVu Sans lacks) into `/usr/share/fonts/noto`, with their OFL notices and package versions in `/usr/share/doc/fonts-noto`; the cache bake fails when a family of `harness.FALLBACK_FAMILIES` is not found. The harness appends these families to every font lookup after the font the plot code chose, and the probe's `missing_glyphs` reports the code points no font of a text covers (gate G10). Arabic and Hebrew render shaped and right to left; the limits are in [Agent network design](../docs/concepts/agent-network.md#render). The image installs the `lib-matplotlib`, `lib-seaborn`, `lib-plotnine` and `lib-altair` extras (the last two since 2026-10-25; vl-convert renders altair in process, with no browser) and links matplotlib's DejaVu Serif and DejaVu Sans Mono into `/usr/share/fonts`, because vl-convert resolves `serif` and `monospace` only to those and draws no text without them. +The image's `fonts` stage installs Debian's `fonts-noto-core` and `fonts-noto-cjk` and copies 37 allowlisted files (Noto Sans, Noto Sans CJK Regular and Bold, and the script families DejaVu Sans lacks) into `/usr/share/fonts/noto`, with their OFL notices and package versions in `/usr/share/doc/fonts-noto`; the cache bake fails when a family of `harness.FALLBACK_FAMILIES` is not found. The harness appends these families to every font lookup after the font the plot code chose, and the probe's `missing_glyphs` reports the code points no font of a text covers (gate G10). Arabic and Hebrew render shaped and right to left; the limits are in [Agent network design](../docs/concepts/agent-network.md#render). The image installs the `lib-matplotlib`, `lib-seaborn`, `lib-plotnine`, `lib-altair`, `lib-pygal` and `lib-letsplot` extras (plotnine and altair since 2026-10-25, pygal and lets-plot since 2026-10-26). Each of the four renders in process, with no browser: vl-convert renders altair, cairosvg and libcairo render pygal, and lets-plot's Kotlin/Native bridge renders lets-plot. The image links matplotlib's DejaVu Serif and DejaVu Sans Mono into `/usr/share/fonts`, because vl-convert resolves `serif` and `monospace` only to those and draws no text without them, and since 2026-10-26 DejaVu Sans, which lets-plot draws its default text in. Since 2026-10-26 it also installs Debian's `libcairo2` for cairosvg (with `libfontconfig1` and Debian's DejaVu core fonts, which left the seaborn, plotnine and altair renders byte-identical) and copies `renderer/fonts.conf` to `/opt/anyplot/fonts.conf`: one font directory and no cache directory, for the fontconfig lets-plot's bridge links, which otherwise prints "Fontconfig error" on every export. -`renderer/cloudbuild.yaml` builds, deploys a `candidate` revision without traffic, smokes it (IAM refuses an anonymous caller; with `_SMOKE_RENDER=true`, `/status` reports the launcher and the volume and a real matplotlib render goes through the candidate, with a token minted for the service URL), and promotes it. The first build also creates the service; the deploy adds the service URL to `RENDERER_AUDIENCES` itself, and `_MIN_INSTANCES=1` keeps an instance warm. CI builds the same image before merge and renders a seaborn, a plotnine and an altair plot through the harness in it (`.github/workflows/ci-image.yml`). The deploy flags and the owner tasks are in [Agent network design](../docs/concepts/agent-network.md#serving-and-infrastructure). +`renderer/cloudbuild.yaml` builds, deploys a `candidate` revision without traffic, smokes it (IAM refuses an anonymous caller; with `_SMOKE_RENDER=true`, `/status` reports the launcher and the volume and a real matplotlib render goes through the candidate, with a token minted for the service URL), and promotes it. The first build also creates the service; the deploy adds the service URL to `RENDERER_AUDIENCES` itself, and `_MIN_INSTANCES=1` keeps an instance warm. CI builds the same image before merge and renders a seaborn, a plotnine, an altair, a pygal and a lets-plot plot through the harness in it (`.github/workflows/ci-image.yml`). The deploy flags and the owner tasks are in [Agent network design](../docs/concepts/agent-network.md#serving-and-infrastructure). ### Test @@ -402,14 +402,14 @@ Before the first case the harness renders the catalogue file of the first case o ### Run the smoke set or the full matrix -- The smoke set is 14 cases (tag `smoke`): one generated case per spec, cycling through the perturbations and alternating matplotlib and seaborn, one case each for plotnine and altair (`scatter-basic-plotnine-renamed` and `scatter-basic-altair-renamed`, since 2026-10-25), plus the hand-written `scatter-basic-matplotlib` and `bar-grouped-seaborn`. A run that holds a plotnine or altair case needs that library in `AGENT_LIBRARIES`, which the harness reads from your environment (otherwise the readiness scan blocks the session as `library-disabled`), and a renderer image that carries it (otherwise the preflight ends the run). Until both libraries are enabled by default, export `AGENT_LIBRARIES=matplotlib,seaborn,plotnine,altair` for such a run. +- The smoke set is 16 cases (tag `smoke`): one generated case per spec, cycling through the perturbations and alternating matplotlib and seaborn, one case each for plotnine and altair (`scatter-basic-plotnine-renamed` and `scatter-basic-altair-renamed`, since 2026-10-25) and for pygal and lets-plot (`scatter-basic-pygal-renamed` and `scatter-basic-letsplot-renamed`, since 2026-10-26), plus the hand-written `scatter-basic-matplotlib` and `bar-grouped-seaborn`. A run that holds a plotnine, altair, pygal or lets-plot case needs that library in `AGENT_LIBRARIES`, which the harness reads from your environment (otherwise the readiness scan blocks the session as `library-disabled`), and a renderer image that carries it (otherwise the preflight ends the run). Until these libraries are enabled by default, export `AGENT_LIBRARIES=matplotlib,seaborn,plotnine,altair,pygal,letsplot` for a smoke or full run. ```bash uv run --extra agents python -m agents.evals.matrix --cases smoke \ --renderer remote --render-url https://RENDERER_URL --gcloud-token ``` -- The full matrix is the 247 cases that are not stress cases (127 before plotnine and altair joined). Cap the spend with `--budget-usd`: +- The full matrix is the 367 cases that are not stress cases (247 before pygal and lets-plot joined, 127 before plotnine and altair). Cap the spend with `--budget-usd`: ```bash uv run --extra agents python -m agents.evals.matrix --cases full --budget-usd 4 \ @@ -454,7 +454,7 @@ The rates mean the following: - **Accept-match rate**: runs whose outcome (`ok` counts as accepted) matches the case's `expected` value; cases that expect `unknown` are left out. - **Cost per successful plot**: the whole run's list-price cost divided by the passed runs; the summary also gives the median cost of a passed run. -The diff against a baseline compares the cases both reports hold, with every repeat of each, so a smoke run is measured against the same 14 cases of a full baseline (the 12 matplotlib and seaborn ones against a baseline written before plotnine and altair joined). Each report's pass rate over all of its own runs is an extra row. With 14 cases one run is 7.1 percentage points, more than the 5-point tolerance, so a single case that flips from pass to fail is a regression on the smoke set. +The diff against a baseline compares the cases both reports hold, with every repeat of each, so a smoke run is measured against the same 16 cases of a full baseline (14 against a baseline written before pygal and lets-plot joined, and the 12 matplotlib and seaborn ones against one written before plotnine and altair). Each report's pass rate over all of its own runs is an extra row. With 16 cases one run is 6.25 percentage points, more than the 5-point tolerance, so a single case that flips from pass to fail is a regression on the smoke set. The exit code is one of the following: @@ -620,7 +620,7 @@ After you change `evals/make_stress.py`, regenerate the cases with `uv run --ext ### Run spike X -Spike X is the go or no-go for phase 1: 10 specs times matplotlib and seaborn times 6 datasets (renamed, x10, n=12, n=5000, date, decimal comma) on both arms, under $25 in total. The full matrix was those 120 generated cases plus the hand-written ones (two at spike X, three since 2026-10-12). Since 2026-10-25 the generator writes the same 10 specs and 6 datasets for plotnine and altair too, so the full matrix is 240 generated cases (10 specs times four libraries times 6 datasets) plus the hand-written ones, and `--cases full` needs `AGENT_LIBRARIES` with all four (see [Run the smoke set or the full matrix](#run-the-smoke-set-or-the-full-matrix)). To repeat spike X on its 127 cases, pass `--cases "*-matplotlib*,*-seaborn*"` instead of `--cases full` in the steps below. At list price a spike-X run is estimated at about $1 on the Claude arm and $10 to $15 on the Gemini arm; the whole matrix holds about twice as many cases. The budgets below leave $3 of the $25 for headroom, because a budget is checked between cases. +Spike X is the go or no-go for phase 1: 10 specs times matplotlib and seaborn times 6 datasets (renamed, x10, n=12, n=5000, date, decimal comma) on both arms, under $25 in total. The full matrix was those 120 generated cases plus the hand-written ones (two at spike X, three since 2026-10-12). The generator writes the same 10 specs and 6 datasets for plotnine and altair too (since 2026-10-25) and for pygal and lets-plot (since 2026-10-26), so the full matrix is 360 generated cases (10 specs times six libraries times 6 datasets) plus the hand-written ones, and `--cases full` needs `AGENT_LIBRARIES` with all six (see [Run the smoke set or the full matrix](#run-the-smoke-set-or-the-full-matrix)). To repeat spike X on its 127 cases, pass `--cases "*-matplotlib*,*-seaborn*"` instead of `--cases full` in the steps below. At list price a spike-X run is estimated at about $1 on the Claude arm and $10 to $15 on the Gemini arm; the whole matrix holds about three times as many cases. The budgets below leave $3 of the $25 for headroom, because a budget is checked between cases. 1. Count the catalogue files the network can adapt at all (no model call, about 10 seconds). The report lists the eligible, coupled and blocked pairs per library, the blocked ones by reason (SECURITY findings by rule id, a missing `THEME` block, a save target other than `f"plot-{THEME}.png"`, the map specs): @@ -738,7 +738,7 @@ The exit code is 0 when every action was done, 1 when a case was skipped, failed ### Regenerate the fixtures -The 240 generated cases come from `evals/make_fixtures.py`. After you change it, or when a NumPy upgrade changes a random stream, rerun it and commit the result: +The 360 generated cases come from `evals/make_fixtures.py`. After you change it, or when a NumPy upgrade changes a random stream, rerun it and commit the result: ```bash uv run --extra agents python -m agents.evals.make_fixtures diff --git a/agents/anyplot/code/letsplot_names.py b/agents/anyplot/code/letsplot_names.py new file mode 100644 index 00000000000..20cedd1100b --- /dev/null +++ b/agents/anyplot/code/letsplot_names.py @@ -0,0 +1,233 @@ +"""The names `from lets_plot import *` binds, as static data: `lets_plot.__all__` of lets-plot 4.11.0. + +The normaliser expands a catalogue file's star import into an explicit +`from lets_plot import (...)` (151 of the 325 lets-plot files use one, survey of +2026-10-26), so the SECURITY validator can see every name the code takes from the +package. It never imports lets_plot to learn the names: the agents image does not +install it, and importing a plotting library to rewrite text would run its import +side effects. The list has 210 entries and 209 distinct names (`layer_tooltips` is +listed twice). + +`tests/unit/agents/code/test_letsplot_names.py` pins the set against the installed +package wherever lets-plot is installed (the `lib-letsplot` extra), so a lets-plot +upgrade that adds or removes a name fails that test until the data is refreshed: + + uv run --extra lib-letsplot python -c "import lets_plot; print(sorted(set(lets_plot.__all__)))" + +Paste the printed names here, then update the version in this docstring. +""" + +LETS_PLOT_NAMES: frozenset[str] = frozenset( + { + "GGBunch", + "LetsPlot", + "aes", + "arrow", + "as_discrete", + "coord_cartesian", + "coord_fixed", + "coord_flip", + "coord_map", + "coord_polar", + "element_blank", + "element_geom", + "element_line", + "element_markdown", + "element_rect", + "element_text", + "expand_limits", + "facet_grid", + "facet_wrap", + "flavor_darcula", + "flavor_high_contrast_dark", + "flavor_high_contrast_light", + "flavor_solarized_dark", + "flavor_solarized_light", + "flavor_standard", + "font_family_info", + "font_metrics_adjustment", + "geom_abline", + "geom_area", + "geom_area_ridges", + "geom_band", + "geom_bar", + "geom_bin2d", + "geom_blank", + "geom_boxplot", + "geom_bracket", + "geom_bracket_dodge", + "geom_contour", + "geom_contourf", + "geom_count", + "geom_crossbar", + "geom_curve", + "geom_density", + "geom_density2d", + "geom_density2df", + "geom_dotplot", + "geom_errorbar", + "geom_freqpoly", + "geom_function", + "geom_hex", + "geom_histogram", + "geom_hline", + "geom_imshow", + "geom_jitter", + "geom_label", + "geom_label_repel", + "geom_line", + "geom_linerange", + "geom_livemap", + "geom_lollipop", + "geom_map", + "geom_path", + "geom_pie", + "geom_point", + "geom_pointdensity", + "geom_pointrange", + "geom_polygon", + "geom_qq", + "geom_qq2", + "geom_qq2_line", + "geom_qq_line", + "geom_raster", + "geom_rect", + "geom_ribbon", + "geom_segment", + "geom_sina", + "geom_smooth", + "geom_spoke", + "geom_step", + "geom_text", + "geom_text_repel", + "geom_tile", + "geom_violin", + "geom_vline", + "geom_ydotplot", + "ggbunch", + "ggdeck", + "gggrid", + "ggmarginal", + "ggplot", + "ggsave", + "ggsize", + "ggtb", + "ggtitle", + "guide_colorbar", + "guide_legend", + "guides", + "labs", + "layer", + "layer_key", + "layer_labels", + "layer_tooltips", + "lims", + "maptiles_lets_plot", + "maptiles_solid", + "maptiles_zxy", + "margin", + "position_dodge", + "position_dodgev", + "position_fill", + "position_jitter", + "position_jitterdodge", + "position_nudge", + "position_stack", + "sampling_group_random", + "sampling_group_systematic", + "sampling_pick", + "sampling_random", + "sampling_random_stratified", + "sampling_systematic", + "sampling_vertex_dp", + "sampling_vertex_vw", + "scale_alpha", + "scale_alpha_identity", + "scale_alpha_manual", + "scale_brewer", + "scale_cmapmpl", + "scale_color_brewer", + "scale_color_cmapmpl", + "scale_color_continuous", + "scale_color_discrete", + "scale_color_gradient", + "scale_color_gradient2", + "scale_color_gradientn", + "scale_color_grey", + "scale_color_hue", + "scale_color_identity", + "scale_color_manual", + "scale_color_viridis", + "scale_continuous", + "scale_discrete", + "scale_fill_brewer", + "scale_fill_cmapmpl", + "scale_fill_continuous", + "scale_fill_discrete", + "scale_fill_gradient", + "scale_fill_gradient2", + "scale_fill_gradientn", + "scale_fill_grey", + "scale_fill_hue", + "scale_fill_identity", + "scale_fill_manual", + "scale_fill_viridis", + "scale_gradient", + "scale_gradient2", + "scale_gradientn", + "scale_grey", + "scale_hue", + "scale_identity", + "scale_linetype_identity", + "scale_linetype_manual", + "scale_linewidth", + "scale_linewidth_identity", + "scale_manual", + "scale_shape", + "scale_shape_identity", + "scale_shape_manual", + "scale_size", + "scale_size_area", + "scale_size_identity", + "scale_size_manual", + "scale_stroke", + "scale_stroke_identity", + "scale_viridis", + "scale_x_continuous", + "scale_x_datetime", + "scale_x_discrete", + "scale_x_discrete_reversed", + "scale_x_log10", + "scale_x_log2", + "scale_x_reverse", + "scale_x_time", + "scale_y_continuous", + "scale_y_datetime", + "scale_y_discrete", + "scale_y_discrete_reversed", + "scale_y_log10", + "scale_y_log2", + "scale_y_reverse", + "scale_y_time", + "smooth_labels", + "stat_ecdf", + "stat_sum", + "stat_summary", + "stat_summary_bin", + "theme", + "theme_bw", + "theme_classic", + "theme_gray", + "theme_grey", + "theme_light", + "theme_minimal", + "theme_minimal2", + "theme_none", + "theme_void", + "xlab", + "xlim", + "ylab", + "ylim", + } +) +"""The 209 distinct names of `lets_plot.__all__` 4.11.0; modules are not among them, and `ggsave` is.""" diff --git a/agents/anyplot/code/normalise.py b/agents/anyplot/code/normalise.py index 6bff2b131ab..01bcffbc3a6 100644 --- a/agents/anyplot/code/normalise.py +++ b/agents/anyplot/code/normalise.py @@ -1,81 +1,172 @@ """The normaliser: catalogue code in, code that renders the exact canvas out. `normalise(code, library=...)` serves every library with a save rule -(`regions.SAVE_RULES`: matplotlib, seaborn, plotnine, altair) and makes four rewrites, -each only when it applies: +(`regions.SAVE_RULES`: matplotlib, seaborn, plotnine, altair, pygal, letsplot) and makes +these rewrites, in the order `_steps` runs them, each only when it applies: 1. **Catalogue header and title.** The four-line module docstring that opens every catalogue file (lines `anyplot.ai`, `: `, `Library: ...`, - `Quality: ...`) and the blank lines after it go. Every string literal in the + `Quality: ...`) and the blank lines after it go first, also a second one right after + it (3 lets-plot files carry two, 2026-10-26). Every string literal in the catalogue title form, `[ · ] · [ · ] · anyplot.ai` (also with the legacy `pyplots.ai`, or with the descriptive part ended by - a newline), becomes `""`: it names the catalogue's spec and demo data, and the - catalogue title rule does not apply to user plots. The call that shows it stays, so - code that positions the title keeps working. A title the adapter wrote in its own - words does not have that form and stays, and so does a title built by an f-string. -2. **Save target.** A target that is a path whose file name is the theme f-string, + a newline), becomes `""` after the guards: it names the catalogue's spec and demo + data, and the catalogue title rule does not apply to user plots. The call that shows + it stays, so code that positions the title keeps working. A title the adapter wrote + in its own words does not have that form and stays, and so does a title built by an + f-string. +2. **lets-plot's imports** (letsplot only). `from lets_plot.export import ggsave as + export_ggsave` (17 catalogue files) becomes `... import ggsave` and every + `export_ggsave` becomes `ggsave`, so the save rule sees the save; when the file binds + either name another way, the alias stays with a note. `from lets_plot import *` (151 + files) becomes one parenthesised `from lets_plot import (...)` of exactly the names the + module looks up as globals and does not bind itself (`symtable`), taken from + `letsplot_names.LETS_PLOT_NAMES` without importing lets_plot, in ruff's + order-by-type (natural order, `scale_x_log2` before `scale_x_log10`). The star import + stays in place with a note when it is not a module-level statement or shares its line + with other code, when the file has a second star import or a `global` statement, or + when a lets-plot name the file binds itself may be read before that binding has surely + run: above it, in the binding statement itself (`labs = labs(...)`), or outside the + `if`, loop or `try` block that binds it (0 catalogue files, 2026-10-26). +3. **Save target.** A target that is a path whose file name is the theme f-string, such as `os.path.join(script_dir, f"plot-{THEME}.png")`, `OUT / f"plot-{THEME}.png"`, - or a module-level name assigned one of those (`output_path`), becomes the literal - `f"plot-{THEME}.png"` in every save call of the library's rule (`savefig`, plotnine's - `save` and `ggsave`, altair's `save`, also for its `f"plot-{THEME}.html"`), so the - file lands next to the run form. A single-theme target such as `"plot.png"` stays - (the readiness scan blocks it). -3. **`sys.path` guard and file paths.** A file named `matplotlib.py` or `seaborn.py` - shadows the installed package when run from its own directory, so 120 of 650 - catalogue files strip their directory from `sys.path` first. The run form is - `plot.py`, so the guard goes: every module-level statement that only changes - `sys.path` (an assignment to it, a `sys.path.*()` call, or an `if`, `for`, `while` or - `try` holding only such statements, private helper assignments and `pass`), whatever - `sys` is imported as. Then every module-level assignment that is no longer used and - only served the guard or computed a file location (from `__file__`, `os.path.*`, - `pathlib` or `os.getcwd()`, or from another such name) goes, repeated until none is - left, with the `del` of those names, the comment lines directly above every removed - statement, and each import (`sys`, `os as _os`, `pathlib`, ...) that only they used. - Three files load their modules through `NAME = importlib.import_module("dotted.name")`, - which becomes `import dotted.name as NAME`, and `import importlib` goes once unused. -4. **Canvas.** Every `savefig` loses `bbox_inches=` and `pad_inches=` (`"tight"` crops - the canvas by 30 to 50 px, the documented cause of the drift that the - `impl-review.yml` canvas gate catches), and the dpi is rescaled so that - `figsize * dpi` renders exactly 3200x1800 (16:9) or 2400x2400 (1:1). The figure size - stays; the target is the one the figure's aspect reaches, so `(8, 4.5)` and `(16, 9)` - map to 3200x1800 at dpi 400 and 200, `(6, 6)`, `(12, 12)` and `(16, 16)` to 2400x2400 at - dpi 400, 200 and 150. The new dpi is written on `savefig` (added when missing) and on - every literal `dpi=` of a figure creation call and `set_dpi(...)`, so code that - measures in pixels before saving sees the same dpi. An integer dpi is used when one - hits the target exactly; otherwise the shortest decimal that does (matplotlib - truncates `figsize * dpi` to whole pixels, so `(7, 7)` gets dpi 342.9). When no dpi - hits it exactly, because the aspect is a hair off (`(16, 9.05)`), the dpi renders one - side exactly and the other within the 16 px of the canvas gate (3200x1810); the - change line names the pixels and the offset, and `measure_canvas` and the readiness - scan report it as a `canvas:` note. The figure - size comes from the last literal `set_size_inches(w, h)` (seaborn grids), else from - the single literal `figsize=`. The canvas rewrite is all or nothing: when the figure - size is missing or not a literal, when its aspect reaches neither target within 16 px, - or when a savefig takes `**kwargs`, `bbox_inches` and the dpi stay as they are and a - note says why (`readiness.scan` reports it as a `canvas:` reason). The rewrite is - matplotlib's (`SaveRule.canvas`): plotnine sizes the canvas on its save (`width=`, - `height=`, `units=`, `dpi=`, 3200x1800 in most catalogue files) and altair through - its chart's `width` and `height` times `scale_factor`, padded by its PIL block, so - for those two the step only notes that the render relies on the R3 canvas gate. - -Each rewrite is checked with `ast.parse` and dropped with a note if it would not parse. + `f"{script_dir}/plot-{THEME}.png"`, or a module-level name assigned one of those + (`output_path`), becomes the literal `f"plot-{THEME}.png"` in every save call of the + library's rule (`savefig`, plotnine's `save` and `ggsave`, altair's `save`, pygal's + `render_to_png`, `render_to_file` and `svg2png`, lets-plot's `ggsave`, also for + `f"plot-{THEME}.html"` where the rule admits it), so the file lands next to the run + form. A single-theme target such as `"plot.png"` stays (the readiness scan blocks it). +4. **Save directory** (letsplot only). lets-plot's `ggsave` writes into + `./lets-plot-images` without `path=`, and the plot sandbox refuses the `os.mkdir`, so + every `ggsave` of a theme target gets `path="."` (31 PNG saves lack it) and a computed + `path=` (`str(_script_dir)`) or `path=None` becomes `"."`; another literal directory + stays with a note. The 25 catalogue files that then move, copy or delete what + lets-plot wrote there lose that epilogue: a module-level statement + without a save that mentions `"lets-plot-images"` (or a name bound to it or to a + `ggsave` return value), is made only of `if`, `for`, `try` (each `except` only + `pass`), expressions, assignments and `import shutil` or `pathlib`, and calls only + `EPILOGUE_CALLS` and `EPILOGUE_METHODS` goes, with the names bound to the directory and + an `import shutil` that nothing kept uses; every `png_path = ggsave(...)` whose name + only fed the epilogue becomes the bare save. Any other statement that mentions the directory + stays with a note, and the SECURITY validator blocks the file. +5. **HTML and SVG writes** (pygal only). A module-level `with open(, "wb" | "w") as f:` whose one statement writes `X.render()` (mode + `"wb"`), `X.render(is_unicode=True)` or `X.render().decode()` (mode `"w"`, UTF-8) + becomes `X.render_to_file(f"plot-{THEME}.html")`, which writes the same bytes (158 + catalogue files; the HTML and the PNG were byte-identical on all 156 that run, + 2026-10-25). Every other module-level `open()` write of the theme `.html` or `.svg` + file (a hand-built page, a post-processed SVG; besides its writes, the body may assign + names that nothing outside it reads, as heatmap-basic's `pygal_svg = chart.render(...)` + does) and every `X.render_to_file()` goes, unless the same target is named + again elsewhere in the file, and so do the module-level assignments that only fed a + removed write, repeated until none is left (never one that holds a save call). The + step then runs again until it changes nothing, because a removed feeder can free a + target that was named again. The SECURITY validator has no pinnable form for an + `open()` write, and the plot sandbox refuses `.svg` files. +6. **Guards.** A file named `matplotlib.py`, `seaborn.py` or `pygal.py` shadows the + installed package when run from its own directory, so many catalogue files strip + their directory from `sys.path` first. The run form is `plot.py`, so the guard goes, + in three steps. `X = importlib.util.find_spec("pkg")` followed by + `if X and X.origin != __file__:` whose branch holds only imports becomes those imports + (the branch the run form takes), and `X` goes once unused. A module-level `try:` of + imports only, with no `except` or `else`, whose `finally:` holds only guard statements + (the ones the next step removes: a change of `sys.path` or `sys.modules`, or + `sys.dont_write_bytecode = ...`), becomes its imports. Then every module-level + statement that only changes `sys.path` or `sys.modules` (an assignment to + `sys.path`, `sys.dont_write_bytecode = ...`, a + `sys.path.*()` or `sys.modules.*()` call, `del sys.modules[...]`, or an `if`, `for`, + `while` or `try` holding only such statements, private helper assignments and + `pass`), whatever `sys` is imported as, goes. Then every module-level assignment that + is no longer used and only served the guard, read `sys.path` (`_saved = sys.path[:]`) + or computed a file location (from `__file__`, `os.path.*`, `pathlib` or + `os.getcwd()`, or from another such name) goes, repeated until none is left, with the + `del` of those names, the comment lines directly above every removed statement, and + each import (`sys`, `os as _os`, `pathlib`, ...) that only they used. Files that load + their modules through `NAME = importlib.import_module("dotted.name")[.ATTR]`, also + after `from importlib import import_module`, get `import dotted.name as NAME` or + `from dotted.name import ATTR as NAME`, and the importlib import goes once unused. +7. **Canvas** (`SaveRule.canvas`), last: + * matplotlib and seaborn (`figure`): every `savefig` loses `bbox_inches=` and + `pad_inches=` (`"tight"` crops the canvas by 30 to 50 px, the documented cause of + the drift that the `impl-review.yml` canvas gate catches), and the dpi is rescaled + so that `figsize * dpi` renders exactly 3200x1800 (16:9) or 2400x2400 (1:1). The + figure size stays; the target is the one the figure's aspect reaches, so + `(8, 4.5)` and `(16, 9)` map to 3200x1800 at dpi 400 and 200, `(6, 6)`, `(12, 12)` + and `(16, 16)` to 2400x2400 at dpi 400, 200 and 150. The new dpi is written on + `savefig` (added when missing) and on every literal `dpi=` of a figure creation call + and `set_dpi(...)`, so code that measures in pixels before saving sees the same + dpi. An integer dpi is used when one hits the target exactly; otherwise the + shortest decimal that does (matplotlib truncates `figsize * dpi` to whole pixels, + so `(7, 7)` gets dpi 342.9). When no dpi hits it exactly, because the aspect is a + hair off (`(16, 9.05)`), the dpi renders one side exactly and the other within the + 16 px of the canvas gate (3200x1810); the change line names the pixels and the + offset, and `measure_canvas` and the readiness scan report it as a `canvas:` note. + The figure size comes from the last literal `set_size_inches(w, h)` (seaborn + grids), else from the single literal `figsize=`. The rewrite is all or nothing: + when the figure size is missing or not a literal, when its aspect reaches neither + target within 16 px, or when a savefig takes `**kwargs`, `bbox_inches` and the dpi + stay as they are and a note says why. + * pygal (`pygal`): the PNG is the chart's `width` x `height` (pygal's 800x600 when + neither is set), or the output size of a `cairosvg.svg2png` save (a literal, or a + module-level name bound once to one, as in ecg-twelve-lead). 98 catalogue + charts are 4800x2700 and 4 are 3600x3600 (files from May 2026), over R2's pixel cap. + A chart whose literal size has exactly the 16:9 or 1:1 aspect keeps its size, so the + layout stays, and its protected save renders the target instead: + `X.render_to_png(f"plot-{THEME}.png")` becomes `cairosvg.svg2png(bytestring= + X.render(), write_to=f"plot-{THEME}.png", output_width=3200, output_height=1800)` + (with `import cairosvg` added where the file imports none above the save), and an + `svg2png` save gets the two keywords. The chart is the one the save renders (traced + through `render`, `encode`, `decode`, `replace` and module-level names bound to + them). An `svg2png` whose bytestring traces to no chart renders the root `` its + string literals build, sized by `width=` and `height=` literals or by module-level + numbers they interpolate (dendrogram-radial, histogram-2d, upset-basic and + ternary-density draw their SVG by hand at 4800x2700 or 3600x3600); failing both, + the file's only chart. A computed size, a size set after the constructor (a store to + `chart.width` or `chart.height`, a `chart.config(...)` call, a `pygal.Config` + argument, a `render()` keyword), another aspect, several charts or a save that + writes no PNG leave the save alone with a note, and the size is then unknown, never + pygal's 800x600. + * lets-plot (`letsplot`): only `scale=` on the protected PNG save changes, never + `ggsize` or `w`/`h`, so text and ticks keep their size relative to the plot. The + size is the literal `ggsize` in the saved plot's `+` chain, else the file's only + literal `ggsize` (when no grid is drawn), else lets-plot's 600x400; `gggrid` and + `ggbunch` without a `ggsize` of their own size themselves from their panels and are + left alone with a note. lets-plot renders `floor(size * scale)` (scale 2 by default, + 1 with `w`/`h` or `dpi=`; `w`/`h` in px, or in `unit` at `dpi`, 300 by default). + The model is exact for px, for `dpi=` and for the default scale; `w`/`h` in a + physical unit can land one pixel short of it (lets-plot 4.11.0 renders + `w=16, h=9, unit="in", dpi=100, scale=2` at 3200x1799, within the 16 px). + The scale is the shortest decimal that renders the target exactly, else one within + 16 px; when none does and the render is over R2's pixel cap, the shortest decimal + that fits it inside the nearer target, the largest of its length (mosaic-categorical: + 1.875, 3000x1800; R3 pads the rest). + * plotnine and altair (None): plotnine sizes the canvas on its save (`width=`, + `height=`, `units=`, `dpi=`, 3200x1800 in most catalogue files) and altair through + its chart's `width` and `height` times `scale_factor`, padded by its PIL block, so + for those two the step only notes that the render relies on the R3 canvas gate. + +Each step is checked with `ast.parse` and dropped with a note if it would not parse. The result is idempotent: `normalise(normalise(x)) == normalise(x)`. """ import ast import math import re +import symtable +import textwrap from collections.abc import Callable from dataclasses import dataclass from fractions import Fraction from functools import partial +from .letsplot_names import LETS_PLOT_NAMES from .regions import ( HTML_SUFFIX, HTML_TARGET, PNG_SUFFIX, SAVE_RULES, STANDARD_TARGET, + THEME_NAME, Number, Regions, SavefigCall, @@ -99,15 +190,68 @@ SQUARE = (2400, 2400) TARGETS = (LANDSCAPE, SQUARE) TOLERANCE = 16 # px per axis, the impl-review.yml canvas gate +MAX_IMAGE_PIXELS = 3200 * 1800 * 2 +"""R2's pixel cap (`render.png.MAX_IMAGE_PIXELS`, pinned by a test): a larger PNG is refused outright.""" DEFAULT_DPI = 100 # matplotlib's figure.dpi; savefig.dpi defaults to the figure's DROPPED_SAVEFIG_KEYWORDS = ("bbox_inches", "pad_inches") HEADER_FIRST_LINE = "anyplot.ai" MAX_DPI_DECIMALS = 6 +SVG_SUFFIX = ".svg" + +PYGAL_DEFAULT_SIZE = (800, 600) # pygal's Config width and height +PYGAL_PASSTHROUGH = frozenset({"render", "encode", "decode", "replace"}) +"""Calls that hand on the chart's SVG unchanged in size, from `X.render()` to the bytes `svg2png` reads; no +catalogue file's `replace` on the SVG text touches its `viewBox` or a size (2026-10-26).""" +PYGAL_NOT_CHARTS = frozenset({"Config"}) +"""Capitalised names of the pygal module that are no chart class (`pygal.Config` carries settings).""" +PYGAL_RENDER_KEYWORDS = frozenset({"is_unicode"}) +"""The one `render()` keyword that leaves the chart's config alone; every other one goes into it (`width=`).""" +PYGAL_SIZE_ATTRIBUTES = frozenset({"width", "height"}) +CAIROSVG = "cairosvg" + +LETSPLOT_SAVE = "ggsave" +LETSPLOT_MODULES = frozenset({"lets_plot", "lets_plot.export"}) +LETSPLOT_IMAGES = "lets-plot-images" +LETSPLOT_GRIDS = frozenset({"gggrid", "ggbunch", "GGBunch"}) +LETSPLOT_DEFAULT_SIZE = (600, 400) # lets-plot's plot size without ggsize (probed 2026-10-26) +LETSPLOT_DEFAULT_DPI = 300 # ggsave's dpi for `w`/`h` in a physical unit +LETSPLOT_PER_INCH = {"in": Fraction(1), "cm": Fraction(100, 254), "mm": Fraction(10, 254)} +EPILOGUE_CALLS = frozenset( + { + "shutil.move", + "shutil.copy", + "shutil.copy2", + "shutil.copyfile", + "shutil.rmtree", + "os.rename", + "os.replace", + "os.remove", + "os.unlink", + "os.rmdir", + "os.listdir", + "os.path.exists", + "os.path.isfile", + "os.path.isdir", + "os.path.join", + "str", + "pathlib.Path", + } +) +"""The calls (resolved through the file's imports) a lets-plot-images epilogue may make: file moves, copies, +deletions, listings, existence tests and path joins (the 25 catalogue epilogues, 2026-10-26).""" +EPILOGUE_METHODS = frozenset({"exists", "glob", "iterdir", "is_file", "rename", "replace", "unlink", "rmdir"}) +"""The `pathlib.Path` methods an epilogue may call on any receiver.""" +EPILOGUE_MODULES = frozenset({"shutil", "pathlib"}) +"""The modules an epilogue may import inside itself; `import shutil` goes once nothing kept uses it.""" _MODULE_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*") _COMMA_GAP = re.compile(r"\s*,\s*") +_DIGITS = re.compile(r"(\d+)") _LINE_TAIL = re.compile(r"\s*,?\s*(?:#.*)?") _WHITESPACE = re.compile(r"\s+") +_SVG_START_TAG = re.compile(r"])([^>]*)>") # an `` start tag and its attributes +_SVG_PIXELS = re.compile(r"\s*(\d+(?:\.\d+)?)\s*(?:px)?\s*") +_SVG_HOLE = re.compile(r"\s*\x00(\d+)\x00\s*(?:px)?\s*") # a `{W}` of an f-string, as `_template` marks it # `[ · ] · [ · ] · anyplot.ai`, also with the # legacy `pyplots.ai`, or with the descriptive part ended by a newline instead of ` · `. _CATALOGUE_TITLE = re.compile( @@ -116,6 +260,8 @@ ) TextEdit = tuple[int, int, str] # replace code[start:end] with the text +StepResult = tuple[str, list[str], list[str]] # the code, the changes made, the notes on what was left alone +Step = Callable[[str], StepResult] @dataclass(frozen=True, slots=True) @@ -131,9 +277,12 @@ class Normalised: class Canvas: """The canvas a source renders, as far as its literals tell. - `pixels` is what matplotlib writes for `figsize` at `dpi`: each side truncated to - whole pixels, as `RendererAgg` does. `target` is the nearer of the two canvases - (the impl-review.yml rule). `note` is None exactly when `pixels == target`. + `pixels` is what the library writes: for matplotlib `figsize` at `dpi`, each side + truncated to whole pixels as `RendererAgg` does; for pygal the chart's or the root + ``'s size, or the `svg2png` output size, rounded as cairosvg does; for lets-plot + the plot size times the scale, floored. `figsize` and `dpi` are matplotlib's only. + `target` is the nearer of the two canvases (the impl-review.yml rule). `note` is None + exactly when `pixels == target`. """ figsize: tuple[Number, Number] | None @@ -163,14 +312,7 @@ def normalise_report(code: str, *, library: str) -> Normalised: return Normalised(code, (), (f"syntax: line {exc.lineno} does not parse; nothing was normalised",)) changes: list[str] = [] notes: list[str] = [] - steps: tuple[tuple[str, Callable[[str], tuple[str, list[str], list[str]]]], ...] = ( - ("header", _strip_header), - ("savefig target", partial(_rewrite_savefig_targets, library=library)), - ("sys.path guard", _strip_path_guard), - ("title", _strip_title), - ("canvas", partial(_normalise_canvas, library=library)), - ) - for name, step in steps: + for name, step in _steps(library): new_code, step_changes, step_notes = step(code) if new_code != code: try: @@ -184,6 +326,33 @@ def normalise_report(code: str, *, library: str) -> Normalised: return Normalised(code, tuple(changes), tuple(notes)) +def _steps(library: str) -> list[tuple[str, Step]]: + """The rewrites of the module docstring in the order they run; each library-specific one for its library only. + + lets-plot's alias rename runs before the save-target step, which matches calls by + the save name; its save directory runs after it, on the literal targets, and before + the guard, which then removes the file-location helpers that `path=` and the + epilogue used. pygal's HTML step also runs before the guard, which removes the + output-directory helpers of the writes it rewrote or dropped. + """ + steps: list[tuple[str, Step]] = [("header", _strip_header)] + if library == "letsplot": + steps += [("save alias", _rename_save_alias), ("star import", _expand_star_import)] + steps.append(("savefig target", partial(_rewrite_savefig_targets, library=library))) + if library == "letsplot": + steps += [("save directory", _letsplot_save_paths), ("save epilogue", _strip_letsplot_epilogue)] + if library == "pygal": + steps.append(("html", _pygal_html_writes)) + steps += [ + ("find_spec guard", _strip_find_spec_guard), + ("try guard", _hoist_try_guard), + ("sys.path guard", _strip_path_guard), + ("title", _strip_title), + ("canvas", partial(_normalise_canvas, library=library)), + ] + return steps + + # --- canvas ------------------------------------------------------------------------------ @@ -230,10 +399,16 @@ def effective_dpi(regions: Regions, call: SavefigCall) -> tuple[Number | None, s def measure_canvas(code: str, *, library: str = "matplotlib") -> Canvas: """The canvas `code` renders, judged from its literals; raises `SyntaxError` if it does not parse. - Only matplotlib's figure size times dpi can be read (`SaveRule.canvas`); for the other - libraries the canvas is a note that the render relies on the R3 gate. + matplotlib's figure size times dpi, pygal's chart or `svg2png` output size and + lets-plot's plot size times the save's scale can be read (`SaveRule.canvas`); for + plotnine and altair the canvas is a note that the render relies on the R3 gate. """ - if not save_rule(library).canvas: + kind = save_rule(library).canvas + if kind == "pygal": + return _measure_pygal(code) + if kind == "letsplot": + return _measure_letsplot(code) + if kind != "figure": return Canvas( None, None, None, None, f"{library} sizes its canvas on the save, not through a matplotlib figure" ) @@ -248,7 +423,7 @@ def measure_canvas(code: str, *, library: str = "matplotlib") -> Canvas: if dpi is None: return Canvas(size, None, None, None, dpi_source) pixels = _pixels(size, dpi) - target = min(TARGETS, key=lambda t: abs(pixels[0] - t[0]) + abs(pixels[1] - t[1])) + target = _nearest_target(pixels) for keyword in call.keywords: if keyword.name == "bbox_inches" and not (keyword.literal and keyword.value is None): note = f"savefig bbox_inches= at {keyword.span.lines()} crops the {pixels[0]}x{pixels[1]} canvas" @@ -270,6 +445,23 @@ def plan_dpi(size: tuple[Number, Number]) -> tuple[tuple[int, int], str] | str: width, height = float(size[0]), float(size[1]) if not (width > 0 and height > 0 and math.isfinite(width) and math.isfinite(height)): return f"figsize {_fmt_size(size)} is not a positive size" + plan = _plan_factor(size) + if plan is not None: + return plan[0], plan[1] + return ( + f"figsize {_fmt_size(size)} has aspect {width / height:.3f}; no dpi renders 3200x1800 (16:9) " + f"or 2400x2400 (1:1) within {TOLERANCE} px without changing the figure size" + ) + + +def _plan_factor(size: tuple[Number, Number]) -> tuple[tuple[int, int], str, bool] | None: + """The target, the factor literal that renders the positive `size` on it when truncated, and whether exactly. + + matplotlib's dpi and lets-plot's scale are such a factor. An exact hit is preferred + (an integer before a decimal); failing that, a factor that renders one side exactly + and the other within `TOLERANCE`. None when neither target is reached. + """ + width, height = float(size[0]), float(size[1]) ordered = sorted(TARGETS, key=lambda t: abs(math.log((width / height) / (t[0] / t[1])))) for target in ordered: @@ -282,7 +474,7 @@ def exact(d: float, t: tuple[int, int] = target) -> bool: high = min(Fraction(tw + 1) / Fraction(width), Fraction(th + 1) / Fraction(height)) literal = _nicest(low, high, exact) if literal is not None: - return target, literal + return target, literal, True for target in ordered: tw, th = target @@ -294,18 +486,18 @@ def near(d: float, t: tuple[int, int] = target) -> bool: for side, pixels in ((width, tw), (height, th)): literal = _nicest(Fraction(pixels) / Fraction(side), Fraction(pixels + 1) / Fraction(side), near) if literal is not None: - return target, literal - - return ( - f"figsize {_fmt_size(size)} has aspect {width / height:.3f}; no dpi renders 3200x1800 (16:9) " - f"or 2400x2400 (1:1) within {TOLERANCE} px without changing the figure size" - ) + return target, literal, False + return None def _pixels(size: tuple[Number, Number], dpi: float) -> tuple[int, int]: return int(float(size[0]) * dpi), int(float(size[1]) * dpi) +def _nearest_target(pixels: tuple[int, int]) -> tuple[int, int]: + return min(TARGETS, key=lambda t: abs(pixels[0] - t[0]) + abs(pixels[1] - t[1])) + + def _nicest(low: Fraction, high: Fraction, accept: Callable[[float], bool]) -> str | None: """The shortest decimal literal in `[low, high)` that `accept`s, an integer first.""" if low >= high: @@ -323,6 +515,22 @@ def _nicest(low: Fraction, high: Fraction, accept: Callable[[float], bool]) -> s return None +def _nicest_up_to(low: Fraction, high: Fraction, accept: Callable[[float], bool]) -> str | None: + """The shortest decimal literal in `[low, high]` that `accept`s, the largest of its length (`high` itself when + it is short enough), an integer first.""" + for decimals in range(MAX_DPI_DECIMALS + 1): + scale = 10**decimals + numerator = math.floor(high * scale) + if Fraction(numerator, scale) < low: + continue + literal = str(numerator) if decimals == 0 else f"{numerator // scale}.{numerator % scale:0{decimals}d}" + if decimals and literal.endswith("0"): + continue # the same value was tried with fewer decimals + if accept(float(literal)): + return literal + return None + + def _size_value(value: object) -> tuple[Number, Number] | None: if not isinstance(value, tuple | list) or len(value) != 2: return None @@ -340,8 +548,13 @@ def _same_dpi(value: object, literal: str, size: tuple[Number, Number], target: return number is not None and (number == float(literal) or _pixels(size, number) == target) -def _normalise_canvas(code: str, *, library: str) -> tuple[str, list[str], list[str]]: - if not save_rule(library).canvas: +def _normalise_canvas(code: str, *, library: str) -> StepResult: + kind = save_rule(library).canvas + if kind == "pygal": + return _pygal_canvas(code) + if kind == "letsplot": + return _letsplot_canvas(code) + if kind != "figure": note = f"canvas: {library} sizes its canvas on the save, not through a matplotlib figure; nothing to rewrite" return code, [], [note] regions = find_regions(code, library=library) @@ -443,27 +656,735 @@ def _apply(code: str, edits: list[TextEdit]) -> str: return code -# --- header and sys.path guard ----------------------------------------------------------- +def _save_call(tree: ast.Module, index: SourceIndex, save: SavefigCall) -> ast.Call | None: + """The `ast.Call` of a save call `find_regions` reported.""" + for node in ast.walk(tree): + if isinstance(node, ast.Call) and node.lineno == save.span.first_line: + span = index.node_span(node) + if (span.start, span.end) == (save.span.start, save.span.end): + return node + return None -def _strip_header(code: str) -> tuple[str, list[str], list[str]]: +def _keyword_literal(keyword: ast.keyword) -> tuple[object, bool]: + try: + return ast.literal_eval(keyword.value), True + except ValueError, TypeError, SyntaxError, MemoryError, RecursionError: + return None, False + + +def _append_keywords(code: str, index: SourceIndex, call: ast.Call, text: str) -> TextEdit: + """The insertion of `, ` after the last argument of `call` (or `` into an empty call).""" + items: list[ast.expr | ast.keyword] = [*call.args, *call.keywords] + if not items: + end = index.node_span(call).end - 1 + return end, end, text + last = max((index.node_span(item) for item in items), key=lambda span: span.end) + return last.end, last.end, f", {text}" + + +# --- pygal canvas ------------------------------------------------------------------------ + + +def _pygal_aliases(tree: ast.Module) -> set[str]: + return { + alias.asname or alias.name + for stmt in tree.body + if isinstance(stmt, ast.Import) + for alias in stmt.names + if alias.name == "pygal" + } + + +def _is_pygal_chart(node: ast.AST, aliases: set[str]) -> bool: + """`pygal.(...)`: a call of a capitalised attribute of the pygal module other than `Config`.""" + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)): + return False + owner, name = node.func.value, node.func.attr + return isinstance(owner, ast.Name) and owner.id in aliases and name[:1].isupper() and name not in PYGAL_NOT_CHARTS + + +def _last_assignment(body: list[ast.stmt], name: str, before: int) -> ast.expr | None: + """The value of the last module-level `name = ...` above line `before`.""" + values = [ + stmt.value + for stmt in body + if isinstance(stmt, ast.Assign) + and len(stmt.targets) == 1 + and isinstance(stmt.targets[0], ast.Name) + and stmt.targets[0].id == name + and stmt.lineno < before + ] + return values[-1] if values else None + + +@dataclass(frozen=True, slots=True) +class _PygalSize: + """The size the PNG save renders before any output size: `subject` is what has it, `source` where it is set.""" + + size: tuple[Number, Number] + subject: str # "pygal chart" or "hand-built SVG" + source: str # "the saved chart at line 9", "the root at line 244" + + +def _pygal_size(tree: ast.Module, call: ast.Call, name: str) -> _PygalSize | str: + """The size of what the PNG save renders and where it came from, or why it is not known. + + The chart is traced from the save through `PYGAL_PASSTHROUGH` and module-level names. + An `svg2png` whose bytestring traces to no chart takes the size of the root `` + that its string literals build (`_svg_root_size`); failing that, the file's only + chart is the one it renders. + """ + aliases = _pygal_aliases(tree) + node: ast.expr | None = None + if name == "render_to_png" and isinstance(call.func, ast.Attribute): + node = call.func.value + elif name == "svg2png": + node = next((kw.value for kw in call.keywords if kw.arg == "bytestring"), None) + start = node + seen: set[str] = set() + chart: ast.Call | None = None + while node is not None: + if _is_pygal_chart(node, aliases) and isinstance(node, ast.Call): + chart = node + break + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in PYGAL_PASSTHROUGH: + if node.func.attr == "render" and any(kw.arg not in PYGAL_RENDER_KEYWORDS for kw in node.keywords): + return f"the render at line {node.lineno} takes keywords that may set the chart's size" + node = node.func.value + elif isinstance(node, ast.Name) and node.id not in seen: + seen.add(node.id) + node = _last_assignment(tree.body, node.id, call.lineno) + else: + node = None + source = "the saved chart" + if chart is None: + if name == "svg2png" and start is not None: + root = _svg_root_size(tree, start, call.lineno) + if root is not None: + return root + charts = [node for node in ast.walk(tree) if _is_pygal_chart(node, aliases) and isinstance(node, ast.Call)] + if len(charts) != 1: + others = f" (the file has {len(charts)})" if charts else "" + return f"no pygal chart traced from the save at line {call.lineno}{others}" + chart, source = charts[0], "the file's only chart" + if chart.args or any(kw.arg is None for kw in chart.keywords): + return f"the chart at line {chart.lineno} takes positional arguments (a pygal.Config) or **kwargs" + late = _pygal_late_size(tree, chart) + if late is not None: + return late + sides: list[Number] = [] + for side, default in zip(("width", "height"), PYGAL_DEFAULT_SIZE, strict=True): + keyword = next((kw for kw in chart.keywords if kw.arg == side), None) + value = as_number(_keyword_literal(keyword)[0]) if keyword is not None else default + if value is None and keyword is not None and isinstance(keyword.value, ast.Name): + value = _module_number(tree, keyword.value.id) # contour-basic's `width=CANVAS_W` + if value is None or value <= 0: + return ( + f"the chart's {side}= at line {chart.lineno} is not a positive number or a module-level name bound " + "once to one" + ) + sides.append(value) + return _PygalSize((sides[0], sides[1]), "pygal chart", f"{source} at line {chart.lineno}") + + +def _pygal_late_size(tree: ast.Module, chart: ast.Call) -> str | None: + """Why the chart's size may not be its constructor's, or None: a `config=` (a `pygal.Config`), a store to its + `width` or `height` (also through `.config`), or a call of its `config(...)`. pygal reads them all at render.""" + if any(kw.arg == "config" for kw in chart.keywords): + return f"the chart at line {chart.lineno} takes a pygal.Config, which may set its size" + names = { + target.id + for node in ast.walk(tree) + if isinstance(node, ast.Assign) and node.value is chart + for target in node.targets + if isinstance(target, ast.Name) + } + grown = bool(names) + while grown: # `c = chart` + aliases = { + target.id + for node in ast.walk(tree) + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Name) and node.value.id in names + for target in node.targets + if isinstance(target, ast.Name) + } + grown = not aliases <= names + names |= aliases + + def is_chart(node: ast.expr) -> bool: + if isinstance(node, ast.Attribute) and node.attr == "config": + node = node.value + return isinstance(node, ast.Name) and node.id in names + + for node in ast.walk(tree): + if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Store) and node.attr in PYGAL_SIZE_ATTRIBUTES: + if is_chart(node.value): + return f"{ast.unparse(node)} at line {node.lineno} sets the chart's size after its constructor" + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "config": + if is_chart(node.func.value): + call = f"{ast.unparse(node.func)}(...)" + return f"{call} at line {node.lineno} may set the chart's size after its constructor" + return None + + +def _svg_root_size(tree: ast.Module, start: ast.expr, before: int) -> _PygalSize | str | None: + """The size of the root `` that the string literals feeding `start` build, or why it cannot be read. + + None when no literal holds an ` 1: + lines = ", ".join(str(line) for line in sorted({literal.lineno for literal, _, _ in tags})) + return f"no pygal chart traced from the save at line {before}, and {len(tags)} tags at line {lines}" + literal, attributes, holes = tags[0] + where = f"the root at line {literal.lineno}" + sides: list[Number] = [] + for side in ("width", "height"): + match = re.search(rf"(? list[ast.expr]: + """The string literals (`str` constants and f-strings) that build the text `start` evaluates to. + + They are `start`'s own and, for every name it reads, those of the statements above + line `before` (outside function and class bodies) that bind the name or extend it + (`svg = ...`, `svg += ...`, `parts.append(...)`, `.extend`, `.insert`), transitively. + A `PYGAL_PASSTHROUGH` call hands on its receiver only. + """ + statements: list[ast.stmt] = [] + pending: list[ast.stmt] = list(tree.body) + while pending: + stmt = pending.pop() + if isinstance(stmt, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) or stmt.lineno >= before: + continue + statements.append(stmt) + pending.extend(child for child in ast.iter_child_nodes(stmt) if isinstance(child, ast.stmt)) + if isinstance(stmt, ast.Try): + pending.extend(inner for handler in stmt.handlers for inner in handler.body) + + def feeders(name: str) -> list[ast.expr]: + values: list[ast.expr] = [] + for stmt in statements: + if isinstance(stmt, ast.Assign) and any(_binds_name(target, name) for target in stmt.targets): + values.append(stmt.value) + elif isinstance(stmt, ast.AnnAssign | ast.AugAssign) and stmt.value is not None: + if _binds_name(stmt.target, name): + values.append(stmt.value) + elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call): + func = stmt.value.func + if isinstance(func, ast.Attribute) and func.attr in ("append", "extend", "insert"): + if isinstance(func.value, ast.Name) and func.value.id == name: + values.extend(stmt.value.args) + return values + + found: list[ast.expr] = [] + seen: set[str] = set() + stack: list[ast.AST] = [start] + while stack: + node = stack.pop() + if isinstance(node, ast.Constant): + if isinstance(node.value, str): + found.append(node) + elif isinstance(node, ast.JoinedStr): + found.append(node) + stack.extend(part.value for part in node.values if isinstance(part, ast.FormattedValue)) + elif ( + isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in PYGAL_PASSTHROUGH + ): + stack.append(node.func.value) + elif isinstance(node, ast.Name): + if node.id not in seen: + seen.add(node.id) + stack.extend(feeders(node.id)) + elif not isinstance(node, ast.Lambda): + stack.extend(ast.iter_child_nodes(node)) + return found + + +def _binds_name(target: ast.expr, name: str) -> bool: + """Whether an assignment target binds `name`, also inside a tuple or list target.""" + if isinstance(target, ast.Tuple | ast.List): + return any(_binds_name(element, name) for element in target.elts) + return isinstance(target, ast.Name) and target.id == name + + +def _template(literal: ast.expr) -> tuple[str, list[ast.FormattedValue]]: + """A string literal's text with each `{...}` of an f-string as a `\\x00\\x00` hole, and the holes.""" + if isinstance(literal, ast.Constant): + return str(literal.value), [] + parts: list[str] = [] + holes: list[ast.FormattedValue] = [] + for part in literal.values if isinstance(literal, ast.JoinedStr) else []: + if isinstance(part, ast.FormattedValue): + parts.append(f"\x00{len(holes)}\x00") + holes.append(part) + elif isinstance(part, ast.Constant): + parts.append(str(part.value)) + return "".join(parts), holes + + +def _svg_length(tree: ast.Module, text: str, holes: list[ast.FormattedValue]) -> Number | None: + """An `` width or height in px: `4800`, `4800px`, or `{W}` (and `{W}px`) of a module-level number.""" + plain = _SVG_PIXELS.fullmatch(text) + if plain: + value: Number | None = float(plain.group(1)) if "." in plain.group(1) else int(plain.group(1)) + return value if value else None + hole = _SVG_HOLE.fullmatch(text) + if hole is None: + return None + part = holes[int(hole.group(1))] + if part.conversion != -1 or part.format_spec is not None or not isinstance(part.value, ast.Name): + return None + return _module_number(tree, part.value.id) + + +def _module_number(tree: ast.Module, name: str) -> Number | None: + """The positive number a module-level `name = ` (also `a, name = ` or `a = name = `) + binds, when that is the only binding of `name` in the file.""" + sites = [node for bound, _, node in _binding_sites(tree) if bound == name] + if len(sites) != 1: + return None + for stmt in tree.body: + if not isinstance(stmt, ast.Assign): + continue + value = stmt.value + for target in stmt.targets: # also `W = H = 2400` + if isinstance(target, ast.Tuple) and isinstance(value, ast.Tuple) and len(target.elts) == len(value.elts): + pairs = list(zip(target.elts, value.elts, strict=True)) + else: + pairs = [(target, value)] + for element, literal in pairs: + if element is sites[0]: + number = as_number(_literal_value(literal)) + return number if number is not None and number > 0 else None + return None + + +def _pygal_output(tree: ast.Module, call: ast.Call, name: str) -> dict[str, Number] | str: + """`svg2png`'s `output_width=`, `output_height=` and `scale=` (numeric literals, or module-level names bound once + to one: ecg-twelve-lead's `WIDTH, HEIGHT = 3200, 1800`), or why one is neither.""" + values: dict[str, Number] = {} + if name != "svg2png": + return values + for keyword in call.keywords: + if keyword.arg in ("output_width", "output_height", "scale"): + value = as_number(_keyword_literal(keyword)[0]) + if value is None and isinstance(keyword.value, ast.Name): + value = _module_number(tree, keyword.value.id) + if value is None: + where = f"svg2png {keyword.arg}= at line {keyword.lineno}" + return f"{where} is not a number or a module-level name bound once to one" + values[keyword.arg] = value + return values + + +def _pygal_pixels(output: dict[str, Number], size: tuple[Number, Number] | None) -> tuple[int, int] | None: + """The PNG size the save writes (cairosvg's `int(round(...))`): both output sizes, else `size` scaled by the one + output size or by `scale=`; None when that needs `size` and it is not known.""" + out_width, out_height = output.get("output_width"), output.get("output_height") + if out_width and out_height: + return round(out_width), round(out_height) + if size is None: + return None + width, height = float(size[0]), float(size[1]) + if out_width: + return round(out_width), round(height * out_width / width) + if out_height: + return round(width * out_height / height), round(out_height) + scale = output.get("scale", 1) + return round(width * scale), round(height * scale) + + +def _exact_target(size: tuple[Number, Number]) -> tuple[int, int] | None: + """The target whose aspect `size` has exactly: 16:9 or 1:1.""" + aspect = Fraction(size[0]) / Fraction(size[1]) + return next((target for target in TARGETS if aspect == Fraction(*target)), None) + + +def _pygal_save(code: str) -> tuple[ast.Module, SourceIndex, SavefigCall, ast.Call] | str: + """The protected PNG save and its call, or why there is none that renders a PNG.""" tree = ast.parse(code) - if not tree.body: + index = SourceIndex(code) + save = find_regions(code, tree, library="pygal").savefig + call = _save_call(tree, index, save) if save is not None and save.standard_target else None + if save is None or call is None: + return 'no module-level save of f"plot-{THEME}.png"' + if not save_rule("pygal").writes_suffix(save.name, PNG_SUFFIX): + return f"the {save.name} at {save.span.lines()} writes no PNG" + return tree, index, save, call + + +def _measure_pygal(code: str) -> Canvas: + found = _pygal_save(code) + if isinstance(found, str): + return Canvas(None, None, None, None, found) + tree, _, save, call = found + measured = _pygal_size(tree, call, save.name) + output = _pygal_output(tree, call, save.name) + if isinstance(output, str): + return Canvas(None, None, None, None, f"pygal canvas unknown: {output}") + pixels = _pygal_pixels(output, measured.size if isinstance(measured, _PygalSize) else None) + if pixels is None: + return Canvas(None, None, None, None, f"pygal canvas unknown: {measured}") + target = _nearest_target(pixels) + if pixels == target: + return Canvas(None, None, pixels, target, None) + source = measured.source if isinstance(measured, _PygalSize) else "the svg2png output size" + note = f"pygal renders {pixels[0]}x{pixels[1]} ({source}), target {target[0]}x{target[1]}" + return Canvas(None, None, pixels, target, note) + + +def _svg2png_callee(tree: ast.Module, before: int) -> str | None: + """How the file names cairosvg's `svg2png` (`cairosvg.svg2png`, `_cairosvg.svg2png`, `svg2png`) above line + `before`, if it does; an import below the save would come too late for it.""" + for stmt in tree.body: + if stmt.lineno >= before: + break + if isinstance(stmt, ast.Import): + for alias in stmt.names: + if alias.name == CAIROSVG: + return f"{alias.asname or alias.name}.svg2png" + elif isinstance(stmt, ast.ImportFrom) and stmt.module == CAIROSVG and stmt.level == 0: + for alias in stmt.names: + if alias.name == "svg2png": + return alias.asname or alias.name + return None + + +def _import_insertion(tree: ast.Module, index: SourceIndex, module: str) -> tuple[int, int]: + """Where `import ` goes, as `(offset, line)`: in isort order within the block that imports pygal. + + The block is the run of module-level imports on consecutive lines around the first + pygal import; the new line goes before the first straight import of a module that + sorts after it, else before the block's first from-import, else after the block. + """ + body = tree.body + imports = [stmt for stmt in body if isinstance(stmt, ast.Import | ast.ImportFrom)] + if not imports: + docstring = ( + body[0] if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) else None + ) + line = (docstring.end_lineno or docstring.lineno) + 1 if docstring is not None else 1 + return index.line_start(min(line, index.line_count)), line + + def top(stmt: ast.Import | ast.ImportFrom) -> str: + name = stmt.names[0].name if isinstance(stmt, ast.Import) else stmt.module or "" + return name.split(".")[0].lower() + + anchor = next((stmt for stmt in imports if top(stmt) == "pygal"), imports[-1]) + position = body.index(anchor) + first = last = position + while first > 0 and isinstance(body[first - 1], ast.Import | ast.ImportFrom): + if (body[first - 1].end_lineno or 0) + 1 != body[first].lineno: + break + first -= 1 + while last + 1 < len(body) and isinstance(body[last + 1], ast.Import | ast.ImportFrom): + if (body[last].end_lineno or 0) + 1 != body[last + 1].lineno: + break + last += 1 + block = [stmt for stmt in body[first : last + 1] if isinstance(stmt, ast.Import | ast.ImportFrom)] + before: ast.Import | ast.ImportFrom | None = next( + (stmt for stmt in block if isinstance(stmt, ast.Import) and top(stmt) > module), None + ) + before = before or next((stmt for stmt in block if isinstance(stmt, ast.ImportFrom)), None) + if before is not None: + return index.line_start(before.lineno), before.lineno + end = block[-1].end_lineno or block[-1].lineno + return index.line_end(end), end + 1 + + +def _pygal_canvas(code: str) -> StepResult: + found = _pygal_save(code) + if isinstance(found, str): + return code, [], [f"canvas: {found}; nothing to normalise"] + tree, index, save, call = found + measured = _pygal_size(tree, call, save.name) + output = _pygal_output(tree, call, save.name) + if isinstance(output, str): + return code, [], [f"canvas: {output}; the PNG size is left as it is"] + pixels = _pygal_pixels(output, measured.size if isinstance(measured, _PygalSize) else None) + if pixels in TARGETS: return code, [], [] - first = tree.body[0] - if not ( - isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant) and isinstance(first.value.value, str) - ): + if pixels is None or not isinstance(measured, _PygalSize): + return code, [], [f"canvas: {measured}; the PNG size is left as it is"] + size = measured.size + chart = f"{measured.subject} {size[0]:g}x{size[1]:g} ({measured.source})" + target = _exact_target(size) + if target is None: + return code, [], [f"canvas: {chart} has another aspect than 16:9 or 1:1; the PNG size is left as it is"] + tw, th = target + if save.has_splat: + return code, [], [f"canvas: the save at {save.span.lines()} takes *args or **kwargs; left unchanged"] + edits: list[TextEdit] = [] + changes: list[str] = [] + callee = _svg2png_callee(tree, call.lineno) + if save.name == "svg2png": + sizes = {"output_width": tw, "output_height": th} + for keyword in call.keywords: + if keyword.arg in sizes: + span = index.node_span(keyword.value) + edits.append((span.start, span.end, str(sizes.pop(keyword.arg)))) + added = [f"{name}={value}" for name, value in sizes.items()] + if added: + edits.append(_append_keywords(code, index, call, ", ".join(added))) + how = f"{save.func}(output_width={tw}, output_height={th})" + else: + receiver = call.func.value if isinstance(call.func, ast.Attribute) else None + extra = [kw for kw in call.keywords if kw.arg not in ("filename", "dpi")] + if receiver is None or dotted_name(receiver) is None or extra or len(call.args) > 1: + return code, [], [f"canvas: the {save.name} at {save.span.lines()} has another form; left unchanged"] + if callee is None: + callee = f"{CAIROSVG}.svg2png" + offset, line = _import_insertion(tree, index, CAIROSVG) + edits.append((offset, offset, f"import {CAIROSVG}{_newline(index, save.span.last_line) or '\n'}")) + changes.append(f"added import {CAIROSVG} at line {line}") + dpi = next((kw for kw in call.keywords if kw.arg == "dpi"), None) + dpi_text = "" if dpi is None else ", " + code[index.node_span(dpi).start : index.node_span(dpi).end] + receiver_text = code[index.node_span(receiver).start : index.node_span(receiver).end] + new_call = ( + f"{callee}(bytestring={receiver_text}.render(), write_to={STANDARD_TARGET}{dpi_text}, " + f"output_width={tw}, output_height={th})" + ) + span = index.node_span(call) + edits.append((span.start, span.end, new_call)) + how = f"{callee}(output_width={tw}, output_height={th}) in place of {save.name}" + changes.append(f"canvas: {chart} renders {pixels[0]}x{pixels[1]}; the save renders {tw}x{th} through {how}") + return _apply(code, edits), changes, [] + + +# --- lets-plot canvas -------------------------------------------------------------------- + + +def _is_ggsize(node: ast.AST) -> bool: + if not isinstance(node, ast.Call): + return False + func = node.func + return (isinstance(func, ast.Name) and func.id == "ggsize") or ( + isinstance(func, ast.Attribute) and func.attr == "ggsize" + ) + + +def _ggsize_value(call: ast.Call) -> tuple[Number, Number] | None: + if len(call.args) != 2 or call.keywords: + return None + values = [as_number(_literal_value(arg)) for arg in call.args] + if values[0] is None or values[1] is None or values[0] <= 0 or values[1] <= 0: + return None + return values[0], values[1] + + +def _literal_value(node: ast.expr) -> object: + try: + return ast.literal_eval(node) + except ValueError, TypeError, SyntaxError, MemoryError, RecursionError: + return None + + +def _callee_name(func: ast.expr) -> str | None: + """The last dotted component of a callee: `ggsave` for `ggsave(...)` and `lp.ggsave(...)`.""" + if isinstance(func, ast.Attribute): + return func.attr + return func.id if isinstance(func, ast.Name) else None + + +def _letsplot_plot_size(tree: ast.Module, call: ast.Call) -> tuple[tuple[Number, Number] | None, str]: + """The `ggsize` of the plot `call` saves and where it came from, or None and why not.""" + subject = call.args[0] if call.args else next((kw.value for kw in call.keywords if kw.arg == "plot"), None) + seen: set[str] = set() + value = subject + while value is not None: + if isinstance(value, ast.Name): + if value.id in seen: + break + seen.add(value.id) + value = _last_assignment(tree.body, value.id, call.lineno) + continue + sizes: list[ast.Call] = [] + root: ast.expr = value + while isinstance(root, ast.BinOp) and isinstance(root.op, ast.Add): + if _is_ggsize(root.right) and isinstance(root.right, ast.Call): + sizes.append(root.right) # the rightmost ggsize first: the last one added wins + root = root.left + if _is_ggsize(root) and isinstance(root, ast.Call): + sizes.append(root) + if sizes: + size = _ggsize_value(sizes[0]) + where = f"line {sizes[0].lineno}" + return (size, f"ggsize at {where}") if size else (None, f"ggsize at {where} is not two numeric literals") + if isinstance(root, ast.Call) and _callee_name(root.func) in LETSPLOT_GRIDS: + return None, f"{_callee_name(root.func)} at line {root.lineno} has no ggsize of its own" + value = root if isinstance(root, ast.Name) else None + literals = {_ggsize_value(node) for node in ast.walk(tree) if isinstance(node, ast.Call) and _is_ggsize(node)} + grids = any(isinstance(node, ast.Call) and _callee_name(node.func) in LETSPLOT_GRIDS for node in ast.walk(tree)) + if len(literals) == 1 and None not in literals: + size = literals.pop() + if size is not None: + return size, "the file's only ggsize" + if not literals and not grids: + return LETSPLOT_DEFAULT_SIZE, "lets-plot's default size" + sizes_text = f"{len(literals)} distinct ggsize value{'s' if len(literals) != 1 else ''}" + return None, f"no ggsize on the saved plot; the file has {sizes_text}{' and a grid' if grids else ''}" + + +def _letsplot_raster(tree: ast.Module, call: ast.Call) -> tuple[tuple[Fraction, Fraction], Number, str] | str: + """The raster size before `scale=`, the scale in effect and where the size came from; or why it is not known.""" + values: dict[str, object] = {} + for keyword in call.keywords: + if keyword.arg in ("scale", "w", "h", "unit", "dpi"): + value, literal = _keyword_literal(keyword) + if keyword.arg == "unit": + valid = isinstance(value, str) and (value in LETSPLOT_PER_INCH or value == "px") + else: + number = as_number(value) + valid = number is not None and number > 0 + if not literal or not valid: + return f"ggsave {keyword.arg}= at line {keyword.lineno} is not a literal lets-plot takes" + values[keyword.arg] = value + scale, dpi = as_number(values.get("scale")), as_number(values.get("dpi")) + width, height = as_number(values.get("w")), as_number(values.get("h")) + if (width is None) != (height is None): + return f"ggsave at line {call.lineno} sets only one of w= and h=" + if width is not None and height is not None: + unit = str(values.get("unit", "in")) + source = f"w={width:g}, h={height:g}, unit={unit}" + if unit == "px": + return (Fraction(width), Fraction(height)), scale or 1, source + resolution = dpi or LETSPLOT_DEFAULT_DPI + per_pixel = LETSPLOT_PER_INCH[unit] * Fraction(resolution) + size = (Fraction(width) * per_pixel, Fraction(height) * per_pixel) + return size, scale or 1, f"{source} at dpi {resolution:g}" + base, source = _letsplot_plot_size(tree, call) + if base is None: + return source + size = (Fraction(base[0]), Fraction(base[1])) + if dpi is not None: + factor = Fraction(dpi) / 96 + return (size[0] * factor, size[1] * factor), scale or 1, f"{base[0]:g}x{base[1]:g} ({source}) at dpi {dpi:g}" + return size, 2 if scale is None else scale, f"{base[0]:g}x{base[1]:g} ({source})" + + +def _letsplot_pixels(size: tuple[Fraction, Fraction], scale: float) -> tuple[int, int]: + """lets-plot's raster: each side times the scale, floored (formula probes of 2026-10-26). + + Exact for px, `dpi=` and the default scale; for `w`/`h` in a physical unit lets-plot + can write one pixel less (3200x1799 for `w=16, h=9, unit="in", dpi=100, scale=2`). + """ + return math.floor(float(size[0]) * scale), math.floor(float(size[1]) * scale) + + +def _letsplot_save(code: str) -> tuple[ast.Module, SourceIndex, SavefigCall, ast.Call] | str: + tree = ast.parse(code) + index = SourceIndex(code) + save = find_regions(code, tree, library="letsplot").savefig + call = _save_call(tree, index, save) if save is not None and save.standard_target else None + if save is None or call is None: + return 'no module-level ggsave of f"plot-{THEME}.png"' + return tree, index, save, call + + +def _measure_letsplot(code: str) -> Canvas: + found = _letsplot_save(code) + if isinstance(found, str): + return Canvas(None, None, None, None, found) + tree, _, _, call = found + raster = _letsplot_raster(tree, call) + if isinstance(raster, str): + return Canvas(None, None, None, None, f"lets-plot canvas unknown: {raster}") + size, scale, source = raster + pixels = _letsplot_pixels(size, float(scale)) + target = _nearest_target(pixels) + if pixels == target: + return Canvas(None, None, pixels, target, None) + note = f"lets-plot renders {pixels[0]}x{pixels[1]} from {source} at scale {scale:g}, target {target[0]}x{target[1]}" + return Canvas(None, None, pixels, target, note) + + +def _letsplot_canvas(code: str) -> StepResult: + found = _letsplot_save(code) + if isinstance(found, str): + return code, [], [f"canvas: {found}; nothing to normalise"] + tree, index, save, call = found + if save.has_splat: + return code, [], [f"canvas: the ggsave at {save.span.lines()} takes *args or **kwargs; left unchanged"] + raster = _letsplot_raster(tree, call) + if isinstance(raster, str): + return code, [], [f"canvas: {raster}; the render relies on the R3 canvas gate"] + size, scale, source = raster + pixels = _letsplot_pixels(size, float(scale)) + if pixels in TARGETS: return code, [], [] - text = first.value.value.strip() - if not text or text.splitlines()[0].strip() != HEADER_FIRST_LINE: + rendered = f"{source} at scale {scale:g} renders {pixels[0]}x{pixels[1]}" + plan = _plan_factor((float(size[0]), float(size[1]))) + if plan is not None: + target, literal, exact = plan + how = "" if exact else f", within {TOLERANCE} px of {target[0]}x{target[1]}" + else: + target = _nearest_target(pixels) + if pixels[0] * pixels[1] <= MAX_IMAGE_PIXELS: + note = f"canvas: {rendered}; no scale renders 3200x1800 or 2400x2400, so the render relies on the R3 gate" + return code, [], [note] + fit = min(Fraction(target[0]) / size[0], Fraction(target[1]) / size[1]) + + def inside(scale: float, t: tuple[int, int] = target) -> bool: + pw, ph = _letsplot_pixels(size, scale) + return pw <= t[0] and ph <= t[1] + + literal = _nicest_up_to(fit * Fraction(999, 1000), fit, inside) or str(float(fit)) + how = f", inside {target[0]}x{target[1]} under R2's pixel cap" + keyword = next((kw for kw in call.keywords if kw.arg == "scale"), None) + if keyword is not None: + span = index.node_span(keyword.value) + edit: TextEdit = (span.start, span.end, literal) + else: + edit = _append_keywords(code, index, call, f"scale={literal}") + after = _letsplot_pixels(size, float(literal)) + change = f"canvas: {rendered}; scale={literal} renders {after[0]}x{after[1]}{how}" + return _apply(code, [edit]), [change], [] + + +# --- header and title -------------------------------------------------------------------- + + +def _is_header(stmt: ast.stmt) -> bool: + if not (isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant) and isinstance(stmt.value.value, str)): + return False + text = stmt.value.value.strip() + return bool(text) and text.splitlines()[0].strip() == HEADER_FIRST_LINE + + +def _strip_header(code: str) -> StepResult: + """Every catalogue header that opens the file: 3 lets-plot files carry a second one (2026-10-26).""" + tree = ast.parse(code) + headers: list[ast.stmt] = [] + for stmt in tree.body: + if not _is_header(stmt): + break + headers.append(stmt) + if not headers: return code, [], [] - span = SourceIndex(code).lines_span(first) - new_code = _rewrite_lines(code, set(range(span.first_line, span.last_line + 1)), {}) - return new_code, [f"removed the catalogue header at {span.lines()}"], [] + index = SourceIndex(code) + spans = [index.lines_span(stmt) for stmt in headers] + lines = {line for span in spans for line in range(span.first_line, span.last_line + 1)} + return _rewrite_lines(code, lines, {}), [f"removed the catalogue header at {span.lines()}" for span in spans], [] -def _strip_title(code: str) -> tuple[str, list[str], list[str]]: +def _strip_title(code: str) -> StepResult: tree = ast.parse(code) in_fstrings = {id(part) for node in ast.walk(tree) if isinstance(node, ast.JoinedStr) for part in node.values} index = SourceIndex(code) @@ -484,10 +1405,611 @@ def _strip_title(code: str) -> tuple[str, list[str], list[str]]: return _apply(code, [(span.start, span.end, '""') for span in spans]), changes, [] +# --- lets-plot imports ------------------------------------------------------------------- + + +def _alias_span(index: SourceIndex, alias: ast.alias) -> tuple[int, int]: + end_line, end_col = alias.end_lineno or alias.lineno, alias.end_col_offset or alias.col_offset + return index.offset(alias.lineno, alias.col_offset), index.offset(end_line, end_col) + + +def _binding_sites(tree: ast.Module) -> list[tuple[str, int, ast.AST]]: + """Every place the file binds a name, in any scope: `(name, line, node)`.""" + sites: list[tuple[str, int, ast.AST]] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Name) and not isinstance(node.ctx, ast.Load): + sites.append((node.id, node.lineno, node)) + elif isinstance(node, ast.arg): + sites.append((node.arg, node.lineno, node)) + elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef): + sites.append((node.name, node.lineno, node)) + elif isinstance(node, ast.alias) and node.name != "*": + sites.append(((node.asname or node.name).split(".")[0], node.lineno, node)) + elif isinstance(node, ast.ExceptHandler | ast.MatchAs | ast.MatchStar) and node.name: + sites.append((node.name, node.lineno, node)) + elif isinstance(node, ast.MatchMapping) and node.rest: + sites.append((node.rest, node.lineno, node)) + elif isinstance(node, ast.Global | ast.Nonlocal): + sites.extend((name, node.lineno, node) for name in node.names) + return sites + + +def _rename_save_alias(code: str) -> StepResult: + """`from lets_plot.export import ggsave as export_ggsave` -> `... import ggsave`, every `export_ggsave` too.""" + tree = ast.parse(code) + saves = [ + alias + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.level == 0 and node.module in LETSPLOT_MODULES + for alias in node.names + if alias.name == LETSPLOT_SAVE + ] + renamed = [alias for alias in saves if alias.asname not in (None, LETSPLOT_SAVE)] + if not renamed: + return code, [], [] + names = {alias.asname for alias in renamed if alias.asname is not None} + clashes = sorted( + { + (name, line) + for name, line, node in _binding_sites(tree) + if name in names | {LETSPLOT_SAVE} and not any(node is alias for alias in saves) + } + ) + if clashes: + name, line = clashes[0] + note = f"save alias: {name} is also bound at line {line}; {', '.join(sorted(names))} left unchanged" + return code, [], [note] + index = SourceIndex(code) + edits: list[TextEdit] = [(*_alias_span(index, alias), LETSPLOT_SAVE) for alias in renamed] + uses = [node for node in ast.walk(tree) if isinstance(node, ast.Name) and node.id in names] + edits += [(index.node_span(node).start, index.node_span(node).end, LETSPLOT_SAVE) for node in uses] + changes = [ + f"import {LETSPLOT_SAVE} as {alias.asname} -> import {LETSPLOT_SAVE} at line {alias.lineno}" + for alias in renamed + ] + if uses: + changes.append( + f"{', '.join(sorted(names))} -> {LETSPLOT_SAVE} at line {', '.join(str(n.lineno) for n in uses)}" + ) + return _apply(code, edits), changes, [] + + +def _global_lookups(code: str) -> tuple[set[str], set[str]]: + """The names the module looks up as globals and the names its own scope binds, from `symtable`.""" + top = symtable.symtable(code, "plot.py", "exec") + bound = {s.get_name() for s in top.get_symbols() if s.is_assigned() or s.is_imported() or s.is_namespace()} + looked_up = {s.get_name() for s in top.get_symbols() if s.is_referenced()} + tables = list(top.get_children()) + while tables: + table = tables.pop() + tables.extend(table.get_children()) + looked_up |= {s.get_name() for s in table.get_symbols() if s.is_referenced() and s.is_global()} + return looked_up, bound + + +def _reads_before_binding(tree: ast.Module, names: set[str]) -> set[str]: + """The `names` the module may read where it has not surely bound them yet. + + The statements run in order, and a read is safe once a statement that always runs + before it has bound the name: an assignment (also to a tuple or a starred target), an + import, a `def` or a `class`, at the module level or earlier in the same block. A + statement's own reads come first (`theme = theme(...)`), a function body counts where + the function is defined, and a binding inside an `if`, a loop, a `try` or a `match` + holds only inside its block (column-stratigraphic binds `margin` in a loop and reads + it there); a `with` body always runs. A `:=` binds nothing here. + """ + early: set[str] = set() + + def read(node: ast.AST, bound: set[str]) -> None: + early.update( + n.id + for n in ast.walk(node) + if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load) and n.id in names and n.id not in bound + ) + + def targets(node: ast.expr | None) -> set[str]: + if isinstance(node, ast.Name): + return {node.id} + if isinstance(node, ast.Tuple | ast.List): + return set().union(*(targets(element) for element in node.elts)) + return targets(node.value) if isinstance(node, ast.Starred) else set() + + def block(statements: list[ast.stmt], bound: set[str]) -> set[str]: + bound = set(bound) + for stmt in statements: + if isinstance(stmt, ast.If | ast.While): + read(stmt.test, bound) + block(stmt.body, bound) + block(stmt.orelse, bound) + elif isinstance(stmt, ast.For | ast.AsyncFor): + read(stmt.iter, bound) + block(stmt.body, bound | targets(stmt.target)) + block(stmt.orelse, bound) + elif isinstance(stmt, ast.With | ast.AsyncWith): + for item in stmt.items: + read(item.context_expr, bound) + bound |= targets(item.optional_vars) + bound = block(stmt.body, bound) + elif isinstance(stmt, ast.Try | ast.TryStar): + after_body = block(stmt.body, bound) + for handler in stmt.handlers: + if handler.type is not None: + read(handler.type, bound) + block(handler.body, bound | ({handler.name} if handler.name else set())) + block(stmt.orelse, after_body) + block(stmt.finalbody, bound) + elif isinstance(stmt, ast.Match): + read(stmt.subject, bound) + for case in stmt.cases: + read(case.pattern, bound) + if case.guard is not None: + read(case.guard, bound) + block(case.body, bound) + else: + read(stmt, bound) + if isinstance(stmt, ast.Assign): + bound |= set().union(*(targets(target) for target in stmt.targets)) + elif isinstance(stmt, ast.AnnAssign | ast.AugAssign) and stmt.value is not None: + bound |= targets(stmt.target) + elif isinstance(stmt, ast.Import | ast.ImportFrom): + bound |= {(alias.asname or alias.name).split(".")[0] for alias in stmt.names if alias.name != "*"} + elif isinstance(stmt, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef): + bound.add(stmt.name) + return bound + + block(tree.body, set()) + return early + + +def _import_order(name: str) -> tuple[int, list[str | int], str]: + """ruff's isort order-by-type: CONSTANT_CASE, then CamelCase, then the rest, each case-insensitive in natural + order (`scale_x_log2` before `scale_x_log10`).""" + group = 0 if name.isupper() and len(name) > 1 else 1 if name[:1].isupper() else 2 + natural: list[str | int] = [int(part) if part.isdigit() else part for part in _DIGITS.split(name.lower())] + return group, natural, name + + +def _expand_star_import(code: str) -> StepResult: + """`from lets_plot import *` -> `from lets_plot import (...)` of the names the module takes from it.""" + tree = ast.parse(code) + stars = [ + node for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) and any(a.name == "*" for a in node.names) + ] + lets_plot = [star for star in stars if star.module == "lets_plot" and star.level == 0] + if not lets_plot: + return code, [], [] + star = lets_plot[0] + where = f"the star import at line {star.lineno} stays" + if len(stars) > 1: + return code, [], [f"star import: the file has {len(stars)} star imports; {where}"] + if not any(star is stmt for stmt in tree.body): + return code, [], [f"star import: it is not a module-level statement; {where}"] + globals_ = [node.lineno for node in ast.walk(tree) if isinstance(node, ast.Global)] + if globals_: + return code, [], [f"star import: a global statement at line {globals_[0]}; {where}"] + try: + looked_up, bound = _global_lookups(code) + except SyntaxError as exc: + return code, [], [f"star import: symtable refuses line {exc.lineno}; {where}"] + early = sorted(_reads_before_binding(tree, bound & LETS_PLOT_NAMES)) + if early: + return code, [], [f"star import: {', '.join(early)} may be read before the file binds it; {where}"] + index = SourceIndex(code) + span = index.lines_span(star) + statement = index.node_span(star) + rest = code[statement.end : span.end].strip() + if code[span.start : statement.start].strip() or (rest and not rest.startswith("#")): + return code, [], [f"star import: it shares its line with other code; {where}"] + names = sorted((looked_up - bound) & LETS_PLOT_NAMES, key=_import_order) + if not names: + lines = set(range(span.first_line, span.last_line + 1)) + return _rewrite_lines(code, lines, {}), [f"removed the unused `from lets_plot import *` at {span.lines()}"], [] + newline = _newline(index, span.last_line) or "\n" + text = f"from lets_plot import ({newline}" + "".join(f" {name},{newline}" for name in names) + f"){newline}" + change = f"from lets_plot import * -> {len(names)} names at {span.lines()}" + return _rewrite_lines(code, set(), {span.first_line: (span.last_line, text)}), [change], [] + + +# --- lets-plot save directory ------------------------------------------------------------ + + +def _letsplot_save_paths(code: str) -> StepResult: + """`path="."` on every `ggsave` of a theme target; a computed `path=` becomes `"."`.""" + rule = save_rule("letsplot") + directory = f'"{rule.directory}"' + tree = ast.parse(code) + index = SourceIndex(code) + edits: list[TextEdit] = [] + changes: list[str] = [] + notes: list[str] = [] + for node in ast.walk(tree): + if not (isinstance(node, ast.Call) and _callee_name(node.func) in rule.names): + continue + name = _callee_name(node.func) or LETSPLOT_SAVE + target = save_target(node, rule, name) + if target is None or not (is_theme_target(target, PNG_SUFFIX) or is_theme_target(target, HTML_SUFFIX)): + continue + line = f"line {node.lineno}" + if any(kw.arg is None for kw in node.keywords): + notes.append(f"save directory: the {name} at {line} takes **kwargs; left unchanged") + continue + path = next((kw for kw in node.keywords if kw.arg == "path"), None) + if path is None: + anchor = next((kw for kw in node.keywords if kw.value is target), None) + end = index.node_span(anchor if anchor is not None else target).end + edits.append((end, end, f", path={directory}")) + changes.append(f"added path={directory} to {name} at {line}") + elif not isinstance(path.value, ast.Constant) or path.value.value is None: # None is lets-plot's default + span = index.node_span(path.value) + edits.append((span.start, span.end, directory)) + changes.append(f"{name} path={_WHITESPACE.sub(' ', code[span.start : span.end])} -> {directory} at {line}") + elif path.value.value != rule.directory: + notes.append(f"save directory: the {name} at {line} saves into path={path.value.value!r}; left unchanged") + if not edits: + return code, [], notes + return _apply(code, edits), changes, notes + + +def _import_targets(body: list[ast.stmt]) -> dict[str, str]: + """Local name -> the dotted module or object a module-level import binds it to.""" + targets: dict[str, str] = {} + for stmt in body: + if isinstance(stmt, ast.Import): + for alias in stmt.names: + if alias.asname: + targets[alias.asname] = alias.name + else: + targets[alias.name.split(".")[0]] = alias.name.split(".")[0] + elif isinstance(stmt, ast.ImportFrom) and stmt.module and stmt.level == 0: + for alias in stmt.names: + targets[alias.asname or alias.name] = f"{stmt.module}.{alias.name}" + return targets + + +def _resolved(func: ast.expr, imports: dict[str, str]) -> str | None: + name = dotted_name(func) + if name is None: + return None + head, _, rest = name.partition(".") + base = imports.get(head, head) + return f"{base}.{rest}" if rest else base + + +def _strip_letsplot_epilogue(code: str) -> StepResult: + """Remove what moves, copies or deletes the files lets-plot wrote into `./lets-plot-images`.""" + rule = save_rule("letsplot") + tree = ast.parse(code) + body = tree.body + imports = _import_targets(body) + + def has_save(node: ast.AST) -> bool: + return any(isinstance(n, ast.Call) and _callee_name(n.func) in rule.names for n in ast.walk(node)) + + def mentions(node: ast.AST, names: set[str]) -> bool: + return any( + (isinstance(n, ast.Constant) and isinstance(n.value, str) and LETSPLOT_IMAGES in n.value) + or (isinstance(n, ast.Name) and n.id in names) + for n in ast.walk(node) + ) + + def only_moves(stmt: ast.stmt) -> bool: + for node in ast.walk(stmt): + if isinstance(node, ast.Call): + method = node.func.attr if isinstance(node.func, ast.Attribute) else None + if _resolved(node.func, imports) not in EPILOGUE_CALLS and method not in EPILOGUE_METHODS: + return False + elif isinstance(node, ast.Import): + if not all(alias.name in EPILOGUE_MODULES for alias in node.names): + return False + elif isinstance(node, ast.ExceptHandler): + if not all(isinstance(inner, ast.Pass) for inner in node.body): + return False + elif isinstance(node, ast.stmt) and not isinstance( + node, ast.If | ast.For | ast.Try | ast.Expr | ast.Assign | ast.Pass + ): + return False + return True + + images: set[str] = set() # names bound to the directory or to a path inside it + returned: dict[str, list[ast.Assign]] = {} # every `png_path = ggsave(...)` of each name + for stmt in body: + if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name): + name = stmt.targets[0].id + if isinstance(stmt.value, ast.Call) and _callee_name(stmt.value.func) in rule.names: + returned.setdefault(name, []).append(stmt) + elif mentions(stmt.value, images) and only_moves(stmt): + images.add(name) + removed: set[int] = set() + epilogue: list[ast.stmt] = [] + for stmt in body: + if has_save(stmt) or _assigned(stmt) & images: + continue + if mentions(stmt, images | set(returned)) and only_moves(stmt): + removed.add(id(stmt)) + epilogue.append(stmt) + loads = {id(stmt): _loads(stmt) for stmt in body} + + def kept_loads(skip: ast.stmt | None = None) -> set[str]: + return set().union(*(loads[id(s)] for s in body if s is not skip and id(s) not in removed)) + + names_gone: list[ast.stmt] = [] + grown = True + while grown: # the directory names, once nothing kept reads them + grown = False + for stmt in body: + if id(stmt) in removed or not (_assigned(stmt) and _assigned(stmt) <= images): + continue + if not _assigned(stmt) & kept_loads(stmt): + removed.add(id(stmt)) + names_gone.append(stmt) + grown = True + unwrapped = [stmt for name, stmts in returned.items() for stmt in stmts if name not in kept_loads(stmt)] + imports_gone = [ + stmt + for stmt in body + if isinstance(stmt, ast.Import) + and all(alias.name == "shutil" for alias in stmt.names) + and not _import_bindings(stmt)[0] & kept_loads() + ] + removed.update(id(stmt) for stmt in imports_gone) + notes = [ + f"save epilogue: the statement at line {stmt.lineno} uses {LETSPLOT_IMAGES} in other ways; left in place" + for stmt in body + if id(stmt) not in removed and not has_save(stmt) and mentions(stmt, images) + ] + if not removed and not unwrapped: + return code, [], notes + + index = SourceIndex(code) + remove: set[int] = set() + changes: list[str] = [] + for stmt in body: + if id(stmt) not in removed: + continue + span = index.lines_span(stmt) + remove |= _with_comments(index, span) + if stmt in epilogue: + label = "the lets-plot-images epilogue" + elif stmt in imports_gone: + label = f"the unused `{ast.unparse(stmt)}`" + else: + label = f"the unused lets-plot-images name {', '.join(sorted(_assigned(stmt)))}" + changes.append(f"removed {label} at {span.lines()}") + replace: dict[int, tuple[int, str]] = {} + for stmt in unwrapped: + span = index.lines_span(stmt) + value = index.node_span(stmt.value) + replace[span.first_line] = (span.last_line, code[value.start : span.end]) + changes.append( + f"{ast.unparse(stmt.targets[0])} = {LETSPLOT_SAVE}(...) -> {LETSPLOT_SAVE}(...) at {span.lines()}" + ) + return _rewrite_lines(code, remove, replace), changes, notes + + +# --- pygal HTML and SVG writes ----------------------------------------------------------- + + +def _open_mode(call: ast.Call) -> str | None: + """The literal mode of an `open(...)` call, "r" when it has none, None when it is not a literal.""" + mode = call.args[1] if len(call.args) > 1 else next((kw.value for kw in call.keywords if kw.arg == "mode"), None) + if mode is None: + return "r" + return mode.value if isinstance(mode, ast.Constant) and isinstance(mode.value, str) else None + + +def _theme_path(node: ast.expr, suffix: str) -> bool: + return is_theme_target(node, suffix) or _theme_file_path(node, suffix) + + +def _is_call(node: ast.AST, attribute: str) -> bool: + return isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == attribute + + +def _render_receiver(value: ast.expr, mode: str) -> ast.expr | None: + """`X` when `value` is what `X.render_to_file` writes in `mode`: `X.render()` for "wb", `X.render(is_unicode=True)` + or `X.render().decode(["utf-8"])` for "w"; `X` must be a name or a dotted name.""" + receiver: ast.expr | None = None + if mode == "wb" and _is_call(value, "render") and isinstance(value, ast.Call): + if not value.args and not value.keywords and isinstance(value.func, ast.Attribute): + receiver = value.func.value + elif mode == "w" and isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + if value.func.attr == "render" and not value.args and len(value.keywords) == 1: + keyword = value.keywords[0] + if keyword.arg == "is_unicode" and isinstance(keyword.value, ast.Constant) and keyword.value.value is True: + receiver = value.func.value + elif value.func.attr == "decode" and not value.keywords and len(value.args) <= 1: + utf8 = not value.args or ( + isinstance(value.args[0], ast.Constant) and str(value.args[0].value).lower() in ("utf-8", "utf8") + ) + inner = value.func.value + if utf8 and _is_call(inner, "render") and isinstance(inner, ast.Call): + if not inner.args and not inner.keywords and isinstance(inner.func, ast.Attribute): + receiver = inner.func.value + return receiver if receiver is not None and dotted_name(receiver) is not None else None + + +def _pygal_html_writes(code: str) -> StepResult: + """`_pygal_html_once` until it changes nothing: a removed feeder can free a target that was named again.""" + changes: list[str] = [] + while True: + new_code, step_changes, notes = _pygal_html_once(code) + if new_code == code: + return code, changes, notes + try: + ast.parse(new_code) + except SyntaxError: + return code, changes, [*notes, "html: the rewrite would not parse; left unchanged"] + code = new_code + changes.extend(step_changes) + + +def _pygal_html_once(code: str) -> StepResult: + rule = save_rule("pygal") + tree = ast.parse(code) + index = SourceIndex(code) + remove: set[int] = set() + replace: dict[int, tuple[int, str]] = {} + changes: list[str] = [] + notes: list[str] = [] + dropped: list[ast.stmt] = [] + + def named_again(target: ast.expr, stmt: ast.stmt) -> bool: + text = ast.dump(target) + return any( + ast.dump(node) == text + for other in tree.body + if other is not stmt + for node in ast.walk(other) + if isinstance(node, ast.expr) + ) + + for stmt in tree.body: + span = index.lines_span(stmt) + if isinstance(stmt, ast.Expr) and _is_call(stmt.value, "render_to_file") and isinstance(stmt.value, ast.Call): + target = save_target(stmt.value, rule, "render_to_file") + if target is None or not _theme_path(target, SVG_SUFFIX): + continue + if named_again(target, stmt): + notes.append(f"html: the SVG save at {span.lines()} is read again elsewhere; left in place") + else: + remove |= _with_comments(index, span) + changes.append(f"removed the SVG save at {span.lines()}") + continue + if not isinstance(stmt, ast.With): + continue + read_elsewhere = set().union(*(_loads(other) for other in tree.body if other is not stmt)) + write = _theme_write(stmt, read_elsewhere, rule.names) + if write is None: + continue + target, suffix, mode, utf8, values, assignments = write + single = len(values) == 1 and not assignments + receiver = _render_receiver(values[0], mode) if suffix == HTML_SUFFIX and single and utf8 else None + kind = suffix.removeprefix(".").upper() + if receiver is not None: + name = code[index.node_span(receiver).start : index.node_span(receiver).end] + indent = index.line_text(span.first_line)[: stmt.col_offset] + text = f"{indent}{name}.render_to_file({HTML_TARGET}){_newline(index, span.last_line)}" + replace[span.first_line] = (span.last_line, text) + changes.append(f"the HTML write through open() at {span.lines()} -> {name}.render_to_file({HTML_TARGET})") + elif not named_again(target, stmt): + remove |= _with_comments(index, span) + dropped.append(stmt) + changes.append(f"removed the {kind} write through open() at {span.lines()}") + else: + notes.append( + f"html: the {kind} write through open() at {span.lines()} is read again elsewhere; left in place" + ) + + # The module-level assignments that only fed a removed write (the page around the SVG), until none is left. + gone = {id(stmt) for stmt in dropped} + candidates = set().union(*(_loads(stmt) for stmt in dropped)) + grown = bool(dropped) + while grown: + grown = False + for stmt in tree.body: + if id(stmt) in gone or not isinstance(stmt, ast.Assign | ast.AugAssign): + continue + targets = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target] + if not all(isinstance(target, ast.Name) for target in targets): + continue + names = {target.id for target in targets if isinstance(target, ast.Name)} + if not names <= candidates or any( + _callee_name(n.func) in rule.names for n in ast.walk(stmt) if isinstance(n, ast.Call) + ): + continue + if names & set().union( + *(_loads(other) for other in tree.body if id(other) not in gone and other is not stmt) + ): + continue + gone.add(id(stmt)) + span = index.lines_span(stmt) + remove |= _with_comments(index, span) + candidates |= _loads(stmt) + changes.append(f"removed {', '.join(sorted(names))}, which only fed a removed write, at {span.lines()}") + grown = True + if not remove and not replace: + return code, [], notes + return _rewrite_lines(code, remove, replace), changes, notes + + +def _theme_write( + stmt: ast.stmt, read_elsewhere: set[str], saves: frozenset[str] +) -> tuple[ast.expr, str, str, bool, list[ast.expr], int] | None: + """`(target, suffix, mode, utf8, values, assignments)` for `with open(, "w"|"wb") as f:` + whose body writes to `f` (`values` are what it writes) and otherwise only assigns names that no statement outside + it reads (`read_elsewhere`) with values that hold no save call (`saves`), such as heatmap-basic's + `pygal_svg = chart.render(...)` before the write; else None.""" + if not (isinstance(stmt, ast.With) and len(stmt.items) == 1): + return None + item = stmt.items[0] + call = item.context_expr + if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name) and call.func.id == "open"): + return None + if not call.args or len(call.args) > 2 or any(kw.arg not in ("mode", "encoding") for kw in call.keywords): + return None + target = call.args[0] + suffix = next((suffix for suffix in (HTML_SUFFIX, SVG_SUFFIX) if _theme_path(target, suffix)), None) + mode = _open_mode(call) + handle = item.optional_vars.id if isinstance(item.optional_vars, ast.Name) else None + if suffix is None or mode not in ("w", "wb") or handle is None: + return None + values: list[ast.expr] = [] + assignments = 0 + for inner in stmt.body: + names = _assigned(inner) or ( + {inner.target.id} if isinstance(inner, ast.AugAssign) and isinstance(inner.target, ast.Name) else set() + ) + if names: + value = inner.value if isinstance(inner, ast.Assign | ast.AnnAssign | ast.AugAssign) else None + if handle in names or names & read_elsewhere or value is None: + return None + if any(isinstance(n, ast.Call) and _callee_name(n.func) in saves for n in ast.walk(value)): + return None + assignments += 1 + continue + if not (isinstance(inner, ast.Expr) and _is_call(inner.value, "write") and isinstance(inner.value, ast.Call)): + return None + write = inner.value + if not (isinstance(write.func, ast.Attribute) and isinstance(write.func.value, ast.Name)): + return None + if write.func.value.id != handle or len(write.args) != 1 or write.keywords: + return None + values.append(write.args[0]) + if not values: + return None + encodings = [kw.value for kw in call.keywords if kw.arg == "encoding"] + utf8 = all(isinstance(e, ast.Constant) and str(e.value).lower() in ("utf-8", "utf8") for e in encodings) + return target, suffix, mode, utf8, values, assignments + + +# --- guards and file paths --------------------------------------------------------------- + + def _loads(node: ast.AST) -> set[str]: return {n.id for n in ast.walk(node) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load)} +def _with_comments(index: SourceIndex, span: Span) -> set[int]: + """The lines of `span` and the comment lines directly above it.""" + lines = set(range(span.first_line, span.last_line + 1)) + line = span.first_line - 1 + while line >= 1 and index.line_text(line).lstrip().startswith("#"): + lines.add(line) + line -= 1 + return lines + + +def _sys_aliases(body: list[ast.stmt]) -> set[str]: + """The names `sys` is imported as.""" + return { + alias.asname or alias.name + for stmt in body + if isinstance(stmt, ast.Import) + for alias in stmt.names + if alias.name == "sys" + } + + def _is_sys_path(node: ast.expr, aliases: set[str]) -> bool: """`sys.path` (under any name `sys` is imported as) or a subscript of it.""" if isinstance(node, ast.Subscript): @@ -527,13 +2049,23 @@ def _binds(stmt: ast.stmt) -> set[str]: def _is_guard(stmt: ast.stmt, aliases: set[str]) -> bool: - """A statement that only changes `sys.path` or `sys.modules` (compound ones may hold private helpers and `pass`).""" + """A statement that only changes `sys.path` or `sys.modules` (compound ones may hold private helpers and `pass`). + + `del sys.modules[...]` and `sys.dont_write_bytecode = ...` serve the guard too + (pygal's indicator-rsi and plotnine's spiral-timeseries, 2026-10-26). + """ if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call): callee = dotted_name(stmt.value.func) return callee is not None and any( callee.startswith((f"{alias}.path.", f"{alias}.modules.")) for alias in aliases ) + if isinstance(stmt, ast.Delete): + modules = {f"{alias}.modules" for alias in aliases} + return all(isinstance(t, ast.Subscript) and dotted_name(t.value) in modules for t in stmt.targets) if isinstance(stmt, ast.Assign): + flags = {f"{alias}.dont_write_bytecode" for alias in aliases} + if all(dotted_name(target) in flags for target in stmt.targets): + return True return all(_is_sys_path(target, aliases) for target in stmt.targets) if isinstance(stmt, ast.AugAssign): return _is_sys_path(stmt.target, aliases) @@ -575,7 +2107,9 @@ def _computes_path(stmt: ast.stmt, path_names: set[str], os_names: set[str], pat def _theme_file_path(node: ast.expr, suffix: str = PNG_SUFFIX) -> bool: - """A path whose file name is `f"plot-{THEME}"`: `os.path.join(d, f)`, `d / f`, `str(d / f)`.""" + """A path whose file name is `f"plot-{THEME}"`: `os.path.join(d, f)`, `d / f`, `str(d / f)`, `f"{d}/..."`.""" + if isinstance(node, ast.JoinedStr): + return _directory_fstring(node, suffix) if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div): return is_theme_target(node.right, suffix) if isinstance(node, ast.Call) and not node.keywords: @@ -587,7 +2121,28 @@ def _theme_file_path(node: ast.expr, suffix: str = PNG_SUFFIX) -> bool: return False -def _rewrite_savefig_targets(code: str, *, library: str) -> tuple[str, list[str], list[str]]: +def _directory_fstring(node: ast.JoinedStr, suffix: str) -> bool: + """`f"{d}/plot-{THEME}"`: a formatted directory in front of the theme file name (lets-plot's + shap-summary, plotnine's candlestick-volume).""" + if len(node.values) < 4: + return False + *head, before, theme, tail = node.values + return ( + isinstance(before, ast.Constant) + and isinstance(before.value, str) + and before.value.endswith("/plot-") + and isinstance(theme, ast.FormattedValue) + and isinstance(theme.value, ast.Name) + and theme.value.id == THEME_NAME + and theme.conversion == -1 + and theme.format_spec is None + and isinstance(tail, ast.Constant) + and tail.value == suffix + and any(isinstance(part, ast.FormattedValue) for part in head) + ) + + +def _rewrite_savefig_targets(code: str, *, library: str) -> StepResult: rule = save_rule(library) suffixes = ( ((PNG_SUFFIX, STANDARD_TARGET), (HTML_SUFFIX, HTML_TARGET)) if rule.html else ((PNG_SUFFIX, STANDARD_TARGET),) @@ -626,19 +2181,118 @@ def _rewrite_savefig_targets(code: str, *, library: str) -> tuple[str, list[str] return _apply(code, edits), changes, [] -def _import_module_rewrite(stmt: ast.stmt) -> tuple[str, str] | None: - """`(module, name)` for `name = importlib.import_module("module")`.""" +def _find_spec_test(test: ast.expr) -> str | None: + """`X` for the test `X and X.origin != __file__` (or `X is not None and __file__ != X.origin`).""" + if not (isinstance(test, ast.BoolOp) and isinstance(test.op, ast.And) and len(test.values) == 2): + return None + present, origin = test.values + if ( + isinstance(present, ast.Compare) + and len(present.ops) == 1 + and isinstance(present.ops[0], ast.IsNot) + and isinstance(present.comparators[0], ast.Constant) + and present.comparators[0].value is None + ): + present = present.left + if not (isinstance(present, ast.Name) and isinstance(origin, ast.Compare) and len(origin.ops) == 1): + return None + if not isinstance(origin.ops[0], ast.NotEq): + return None + sides = {dotted_name(origin.left), dotted_name(origin.comparators[0])} + return present.id if sides == {f"{present.id}.origin", "__file__"} else None + + +def _dedented(code: str, index: SourceIndex, statements: list[ast.stmt]) -> str: + """The whole lines of `statements`, dedented to the module level.""" + first = index.lines_span(statements[0]).first_line + last = index.lines_span(statements[-1]).last_line + return textwrap.dedent(code[index.line_start(first) : index.line_end(last)]) + + +def _strip_find_spec_guard(code: str) -> StepResult: + """`X = importlib.util.find_spec("pkg")` and `if X and X.origin != __file__: else: ...` -> the imports.""" + tree = ast.parse(code) + body = tree.body + specs: dict[str, ast.Assign] = {} + for stmt in body: + if not (isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name)): + continue + call = stmt.value + if isinstance(call, ast.Call) and dotted_name(call.func) == "importlib.util.find_spec": + specs[stmt.targets[0].id] = stmt + if not specs: + return code, [], [] + index = SourceIndex(code) + replace: dict[int, tuple[int, str]] = {} + hoisted: list[ast.If] = [] + changes: list[str] = [] + notes: list[str] = [] + for stmt in body: + if not isinstance(stmt, ast.If): + continue + name = _find_spec_test(stmt.test) + if name is None or name not in specs or specs[name].lineno > stmt.lineno: + continue + span = index.lines_span(stmt) + if not all(isinstance(inner, ast.Import | ast.ImportFrom) for inner in stmt.body): + notes.append(f"find_spec guard: the if at {span.lines()} holds more than imports; left in place") + continue + replace[span.first_line] = (span.last_line, _dedented(code, index, stmt.body)) + hoisted.append(stmt) + changes.append(f"replaced the find_spec guard at {span.lines()} with its imports") + remove: set[int] = set() + for name, assignment in specs.items(): + users = [stmt for stmt in body if stmt is not assignment and name in _loads(stmt)] + if users and all(any(user is stmt for stmt in hoisted) for user in users): + span = index.lines_span(assignment) + remove |= _with_comments(index, span) + changes.append(f"removed the unused find_spec result {name} at {span.lines()}") + if not replace: + return code, [], notes + return _rewrite_lines(code, remove, replace), changes, notes + + +def _hoist_try_guard(code: str) -> StepResult: + """A module-level `try:` of imports only whose `finally:` only changes `sys.path` -> its imports.""" + tree = ast.parse(code) + aliases = _sys_aliases(tree.body) + if not aliases: + return code, [], [] + index = SourceIndex(code) + replace: dict[int, tuple[int, str]] = {} + changes: list[str] = [] + for stmt in tree.body: + if not (isinstance(stmt, ast.Try) and stmt.finalbody and not stmt.handlers and not stmt.orelse): + continue + if not all(isinstance(inner, ast.Import | ast.ImportFrom) for inner in stmt.body): + continue + if not all(_is_guard(inner, aliases) for inner in stmt.finalbody): + continue + span = index.lines_span(stmt) + replace[span.first_line] = (span.last_line, _dedented(code, index, stmt.body)) + changes.append(f"replaced the try/finally sys.path guard at {span.lines()} with its imports") + if not replace: + return code, [], [] + return _rewrite_lines(code, set(), replace), changes, [] + + +def _import_module_rewrite(stmt: ast.stmt, callees: set[str]) -> str | None: + """The import for `NAME = importlib.import_module("module")[.ATTR]` (`callees` name `import_module`), or None.""" if not (isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name)): return None - call = stmt.value - if not (isinstance(call, ast.Call) and dotted_name(call.func) == "importlib.import_module"): + name, call, attribute = stmt.targets[0].id, stmt.value, None + if isinstance(stmt.value, ast.Attribute): + call, attribute = stmt.value.value, stmt.value.attr + if not (isinstance(call, ast.Call) and dotted_name(call.func) in callees): return None if len(call.args) != 1 or call.keywords or not isinstance(call.args[0], ast.Constant): return None module = call.args[0].value if not isinstance(module, str) or not _MODULE_NAME.fullmatch(module): return None - return module, stmt.targets[0].id + if attribute is None: + return f"import {module}" if module == name else f"import {module} as {name}" + return f"from {module} import {attribute}" + ("" if attribute == name else f" as {name}") def _import_bindings(stmt: ast.Import | ast.ImportFrom) -> tuple[set[str], set[str]]: @@ -649,17 +2303,20 @@ def _import_bindings(stmt: ast.Import | ast.ImportFrom) -> tuple[set[str], set[s return {alias.asname or alias.name for alias in stmt.names}, {(stmt.module or "").split(".")[0]} -def _strip_path_guard(code: str) -> tuple[str, list[str], list[str]]: +def _strip_path_guard(code: str) -> StepResult: tree = ast.parse(code) body = tree.body - sys_aliases = { + sys_aliases = _sys_aliases(body) + import_module = {"importlib.import_module"} | { alias.asname or alias.name for stmt in body - if isinstance(stmt, ast.Import) + if isinstance(stmt, ast.ImportFrom) and stmt.module == "importlib" and stmt.level == 0 for alias in stmt.names - if alias.name == "sys" + if alias.name == "import_module" + } + rewrites = { + id(stmt): rewrite for stmt in body if (rewrite := _import_module_rewrite(stmt, import_module)) is not None } - rewrites = {id(stmt): rewrite for stmt in body if (rewrite := _import_module_rewrite(stmt)) is not None} loads = {id(stmt): _loads(stmt) for stmt in body} os_names, pathlib_names = _module_aliases(body, "os"), _module_aliases(body, "pathlib") @@ -689,11 +2346,13 @@ def _strip_path_guard(code: str) -> tuple[str, list[str], list[str]]: removed = {id(stmt) for stmt in guard} guard_loads: set[str] = set().union(*(loads[id(stmt)] for stmt in guard)) grown = True - while grown: # dead helpers of the guard and dead file-location assignments, until none is left + while grown: # dead helpers of the guard, dead sys.path snapshots and dead file-location assignments grown = False for stmt in body: names = _binds(stmt) - if id(stmt) in removed or not names or not (names <= guard_loads or names <= path_names): + if id(stmt) in removed or not names: + continue + if not (names <= guard_loads or names <= path_names or _mentions_sys_path(stmt, sys_aliases)): continue used = any( names & loads[id(other)] @@ -737,28 +2396,24 @@ def _strip_path_guard(code: str) -> tuple[str, list[str], list[str]]: if id(stmt) not in removed: continue span = index.lines_span(stmt) - remove.update(range(span.first_line, span.last_line + 1)) - line = span.first_line - 1 - while line >= 1 and index.line_text(line).lstrip().startswith("#"): - remove.add(line) - line -= 1 + remove |= _with_comments(index, span) if stmt in guard: label = "the sys.path guard" elif stmt in imports or isinstance(stmt, ast.Delete): label = f"the unused `{ast.unparse(stmt)}`" + elif _mentions_sys_path(stmt, sys_aliases): + label = "an unused sys.path snapshot" else: label = "an unused file-path helper" changes.append(f"removed {label} at {span.lines()}") for stmt in body: - rewrite = rewrites.get(id(stmt)) - if rewrite is None or id(stmt) in removed: + statement = rewrites.get(id(stmt)) + if statement is None or id(stmt) in removed: continue - module, name = rewrite span = index.lines_span(stmt) - statement = f"import {module}" if module == name else f"import {module} as {name}" replace[span.first_line] = (span.last_line, statement + _newline(index, span.last_line)) - changes.append(f"{name} = importlib.import_module({module!r}) -> {statement} at {span.lines()}") + changes.append(f"{ast.unparse(stmt)} -> {statement} at {span.lines()}") notes: list[str] = [] leftover = [stmt for stmt in body if id(stmt) not in removed and _mentions_sys_path(stmt, sys_aliases)] @@ -779,7 +2434,8 @@ def _rewrite_lines(code: str, remove: set[int], replace: dict[int, tuple[int, st """Drop and replace whole lines, then trim blank lines where the removed lines were. At each removal point the blank lines around it shrink to two (PEP 8 between - module-level statements), or to none at the start of the file. + module-level statements), or to none at the start or the end of the file (pygal's + and lets-plot's files end on the writes the normaliser removes). """ index = SourceIndex(code) out: list[str] = [] @@ -801,19 +2457,21 @@ def _rewrite_lines(code: str, remove: set[int], replace: dict[int, tuple[int, st def blank(text: str) -> bool: return bool(text) and not text.strip() + limit = len(out) for join in reversed(joins): + join = min(join, limit) # the blank lines up to a later removal point may have gone with it before = 0 while join - before - 1 >= 0 and blank(out[join - before - 1]): before += 1 after = 0 while join + after < len(out) and blank(out[join + after]): after += 1 - allowed = 0 if join - before == 0 else 2 + at_end = not "".join(out[join + after :]) + allowed = 0 if join - before == 0 or at_end else 2 excess = before + after - allowed - if excess <= 0: - continue - cut_after = min(after, excess) - cut_before = excess - cut_after + cut_after = min(after, max(excess, 0)) + cut_before = max(excess, 0) - cut_after del out[join : join + cut_after] del out[join - cut_before : join] + limit = join - cut_before return "".join(out) diff --git a/agents/anyplot/code/readiness.py b/agents/anyplot/code/readiness.py index 0a93f290876..3581ad31ae1 100644 --- a/agents/anyplot/code/readiness.py +++ b/agents/anyplot/code/readiness.py @@ -8,11 +8,19 @@ validator reports findings or fails (`security: ...`, quoting the validator's rule ids; it is asked only for a library with a normaliser); there is no module-level `THEME = os.getenv("ANYPLOT_THEME", ...)` (`no-theme`); or a save call of the library's rule (`regions.SAVE_RULES`: `savefig`, - plotnine's `save` and `ggsave`, altair's `save`) writes anywhere but `f"plot-{THEME}.png"` - (altair also `f"plot-{THEME}.html"`), or the last save of the PNG is missing or not a - module-level statement (`savefig-target`; a plain string such as `"plot.png"` is - reported as a single-theme target). Every blocking reason is listed; `hints` stays - empty. + plotnine's `save` and `ggsave`, altair's `save`, pygal's `render_to_png`, + `render_to_file` and `svg2png`, lets-plot's `ggsave`) writes anywhere but + `f"plot-{THEME}.png"` (altair, pygal and lets-plot also `f"plot-{THEME}.html"`), a + save name writes another kind of file than its target (`SaveRule.writes_suffix`: + pygal's `render_to_file` writes SVG text, so only the HTML; `render_to_png` and + `svg2png` only the PNG), a save of a library with a save directory + (`SaveRule.directory`: lets-plot's `path="."`) does not pass it, or the last save of + the PNG is missing or not a module-level statement (`savefig-target`; a plain string + such as `"plot.png"` is reported as a single-theme target). Every blocking reason is listed; `hints` stays + empty. What the normaliser leaves in place reaches the scan this way: a lets-plot + star import or save alias it could not rewrite, a pygal `open()` write it kept and + a guard it did not strip are SECURITY findings; a pygal theme `.svg` save it kept is + a `savefig-target`. * `coupled`: eligible, but the code holds literals that only fit the catalogue's own data. Each category becomes one hint for the adapter (a `Note` of at most 300 characters that names the lines) and one `reasons` entry (`limits: 3 lines`). @@ -54,9 +62,15 @@ profile bans importing it, so such code is already blocked. `reasons` can also carry one `canvas:` entry for any status that is not blocked: the -normaliser could not put the canvas on 3200x1800 or 2400x2400 (the figure size is -not a literal or has another aspect), so the render relies on the R3 canvas gate and -its padding fallback. It does not change the status. +normaliser could not put the canvas on 3200x1800 or 2400x2400 (`normalise.measure_canvas`: +matplotlib's figure size is not a literal or has another aspect, a pygal chart's size is +computed, set after its constructor or not 16:9 or 1:1, a lets-plot grid has no `ggsize` +of its own or no scale reaches either canvas; plotnine and altair size their canvas on +the save, which the normaliser does not read). The entry says what the render meets: a +PNG of a known size over R2's pixel cap is refused by R2; any other relies on the R3 +canvas gate and its padding fallback, except a pygal or lets-plot PNG of unknown size +(`UNKNOWN_SIZE_CANVASES`), which can land over the cap, so no padding is promised. It +does not change the status. The 13 map specs are the specs tagged both `plot_type: map` and `data_type: geospatial` in their `specification.yaml`: bubble-map-geographic, @@ -81,14 +95,17 @@ from core.spec_roles import MAP_SPECS from ..schemas import MAX_NOTE_CHARS -from .normalise import SUPPORTED_LIBRARIES, measure_canvas -from .regions import IMPRINT_NAMES, dotted_name, find_regions, save_rule +from .normalise import MAX_IMAGE_PIXELS, SUPPORTED_LIBRARIES, Canvas, measure_canvas +from .regions import HTML_SUFFIX, IMPRINT_NAMES, PNG_SUFFIX, dotted_name, find_regions, save_rule from .validate import validate_security ReadinessStatus = Literal["blocked", "coupled", "clean"] MIN_PALETTE_ENTRIES = 6 +UNKNOWN_SIZE_CANVASES = frozenset({"pygal", "letsplot"}) +"""The canvas rewrites (`SaveRule.canvas`) whose unknown size can be anything: pygal's and lets-plot's catalogue +charts render 4800x2700 or more, over R2's pixel cap, unless the normaliser sets their size.""" MAX_SECURITY_REASONS = 5 MAX_SNIPPET_CHARS = 48 MAX_LINE_SNIPPET_CHARS = 2 * MAX_SNIPPET_CHARS @@ -218,15 +235,28 @@ def scan(normalised_code: str, *, spec_id: str, library: str, enabled_libraries: if regions.theme is None: blocked.append('no-theme: no module-level THEME = os.getenv("ANYPLOT_THEME", ...)') for call in regions.savefig_calls: - if call.standard_target or (rule.html and call.html_target): - continue where = call.span.lines() + suffix = PNG_SUFFIX if call.standard_target else HTML_SUFFIX if rule.html and call.html_target else None + if suffix is not None: + if not rule.writes_suffix(call.name, suffix): + writes = rule.writes.get(call.name, "") + blocked.append( + f"savefig-target: {call.name} writes only {writes} files, not {_snippet(call.target or '')} " + f"at {where}" + ) + continue if call.literal_target is not None: target = _snippet(repr(call.literal_target)) blocked.append(f"savefig-target: single-theme {call.name} target {target} at {where}") else: target = _snippet(call.target or "no file argument") blocked.append(f'savefig-target: {call.name} saves to {target} at {where}, not f"plot-{{THEME}}.png"') + if rule.directory is not None: + directory = f'path="{rule.directory}"' + for call in regions.savefig_calls: + path = next((keyword for keyword in call.keywords if keyword.name == "path"), None) + if path is None or not (path.literal and path.value == rule.directory): + blocked.append(f"savefig-target: {call.name} at {call.span.lines()} does not save with {directory}") if not regions.savefig_calls: blocked.append(f"savefig-target: no {rule.verb} call") elif regions.savefig is None: @@ -246,10 +276,21 @@ def scan(normalised_code: str, *, spec_id: str, library: str, enabled_libraries: hints.append(_hint(prefix, items, advice)) canvas = measure_canvas(normalised_code, library=library) if canvas.note is not None: - reasons.append(_clip(f"canvas: {canvas.note}; the render relies on the R3 canvas gate and padding")) + reasons.append(_canvas_reason(canvas, library)) return Readiness("coupled" if hints else "clean", reasons, hints) +def _canvas_reason(canvas: Canvas, library: str) -> str: + """The `canvas:` reason: the note and what the render meets; R3's padding only for a PNG under R2's pixel cap.""" + if canvas.pixels is not None and canvas.pixels[0] * canvas.pixels[1] > MAX_IMAGE_PIXELS: + outcome = "R2 refuses a PNG over its pixel cap" + elif canvas.pixels is None and save_rule(library).canvas in UNKNOWN_SIZE_CANVASES: + outcome = "the PNG size shows only in the render, and R2 refuses one over its pixel cap" + else: + outcome = "the render relies on the R3 canvas gate and padding" + return _clip(f"canvas: {canvas.note}; {outcome}") + + def _security(code: str, library: str) -> list[str]: """SECURITY validator findings as blocking reasons; a validator that fails blocks too. diff --git a/agents/anyplot/code/regions.py b/agents/anyplot/code/regions.py index 737acd8f887..454d13a96f7 100644 --- a/agents/anyplot/code/regions.py +++ b/agents/anyplot/code/regions.py @@ -22,12 +22,16 @@ `ggsave(plot, f"plot-{THEME}.png", ...)` for plotnine (the file is the second positional of `ggsave`), `chart.save(f"plot-{THEME}.png", scale_factor=4.0)` for altair, whose files also save `f"plot-{THEME}.html"` and reopen the PNG with PIL to - pad it (`_canvas.save(f"plot-{THEME}.png")`). Every call of the library's save names - is a save call; the protected final one is the last module-level statement among - those that write the PNG; + pad it (`_canvas.save(f"plot-{THEME}.png")`), `chart.render_to_png(f"plot-{THEME}.png")` + or `cairosvg.svg2png(bytestring=..., write_to=f"plot-{THEME}.png")` for pygal (with + `chart.render_to_file(f"plot-{THEME}.html")`), and + `ggsave(plot, f"plot-{THEME}.png", path=".", scale=2)` for lets-plot (with the HTML + save). Every call of the library's save names is a save call; the protected final one + is the last module-level statement among those that write the PNG; * the canvas: `figsize=` and `dpi=` on the figure creation call (`plt.subplots`, `plt.figure`, `plt.subplot_mosaic`, `sns.clustermap`), `set_size_inches` and - `set_dpi` on the figure of a seaborn grid, and `dpi=` on savefig; + `set_dpi` on the figure of a seaborn grid, and `dpi=` on savefig (the other libraries + size their canvas elsewhere, `SaveRule.canvas`); * in an adapted working form, the placeholder statement `df = load_user_data()`. Lines are 1-based and inclusive; offsets are 0-based character offsets into the @@ -64,6 +68,9 @@ RegionKind = Literal["theme", "theme_token", "placeholder", "savefig"] SizeMethod = Literal["set_size_inches", "set_dpi"] +CanvasRewrite = Literal["figure", "pygal", "letsplot"] +"""How the normaliser sets a library's canvas: matplotlib's figure size times dpi (`figure`), pygal's chart size +through the PNG save's output size (`pygal`), lets-plot's `scale=` on the PNG save (`letsplot`).""" Number = int | float @@ -73,20 +80,29 @@ class SaveRule: `names` are the callee names (the last dotted component) that save the plot; the file is the positional at `positions[name]` (0 unless listed) or one of - `target_keywords`. `keywords[name]` lists the keywords a save may take, None for any - (matplotlib's `savefig`, whose settings the validator checks one by one). `html` - admits a second save of exactly `f"plot-{THEME}.html"` next to the PNG, `reopens` - a `.open(...)` of the PNG (altair's PIL padding block), and `canvas` says whether the - normaliser's matplotlib canvas rewrite (figure size times dpi) applies. + `target_keywords`; a name in `keyword_only` takes its file by keyword alone + (`cairosvg.svg2png`, whose first positional is the SVG). `keywords[name]` lists the + keywords a save may take, None for any (matplotlib's `savefig`, whose settings the + validator checks one by one). `html` admits a second save of exactly + `f"plot-{THEME}.html"` next to the PNG, `reopens` a `.open(...)` of the PNG (altair's + PIL padding block), `directory` the one literal every save passes as `path=` + (lets-plot, whose `ggsave` writes into `./lets-plot-images` without it), `writes[name]` + the one suffix a save name may write where the names each write one kind of file + (pygal: `render_to_file` writes SVG text, so only the HTML; a name not listed writes any + target of the rule), and `canvas` names the normaliser's canvas rewrite for the library, + None where it has none yet. """ names: frozenset[str] target_keywords: frozenset[str] positions: Mapping[str, int] = field(default_factory=lambda: MappingProxyType({})) keywords: Mapping[str, frozenset[str] | None] = field(default_factory=lambda: MappingProxyType({})) + keyword_only: frozenset[str] = frozenset() html: bool = False reopens: bool = False - canvas: bool = True + directory: str | None = None + writes: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({})) + canvas: CanvasRewrite | None = "figure" @property def verb(self) -> str: @@ -94,11 +110,19 @@ def verb(self) -> str: return "savefig" if "savefig" in self.names and len(self.names) == 1 else "save" def target_position(self, name: str) -> int: + """The position of the file argument of `name`; -1 for a save that takes its file by keyword only.""" + if name in self.keyword_only: + return -1 return self.positions.get(name, 0) def allowed_keywords(self, name: str) -> frozenset[str] | None: return self.keywords.get(name) + def writes_suffix(self, name: str, suffix: str) -> bool: + """Whether the save name `name` may write a file ending in `suffix` (`PNG_SUFFIX` or `HTML_SUFFIX`).""" + only = self.writes.get(name) + return only is None or only == suffix + _PLOTNINE_SAVE_KEYWORDS = frozenset({"filename", "format", "width", "height", "units", "dpi", "limitsize", "verbose"}) """`ggplot.save(filename, format, path, width, height, units, dpi, limitsize, verbose, **kwargs)` without `path=` @@ -107,6 +131,38 @@ def allowed_keywords(self, name: str) -> frozenset[str] | None: """`Chart.save(fp, format, override_data_transformer, scale_factor, mode, vegalite_version, vega_version, vegaembed_version, embed_options, json_kwds, webdriver, engine, inline, **kwargs)`: the size and the HTML embedding only; `webdriver=` and `engine=` choose how the PNG is made and stay with altair's default, vl-convert in process.""" +_PYGAL_SVG2PNG_KEYWORDS = frozenset( + { + "bytestring", + "write_to", + "dpi", + "scale", + "output_width", + "output_height", + "parent_width", + "parent_height", + "background_color", + "negate_colors", + "invert_images", + } +) +"""`cairosvg.svg2png(bytestring, *, file_obj, url, dpi, parent_width, parent_height, scale, unsafe, +background_color, negate_colors, invert_images, write_to, output_width, output_height)` without `url=` and +`file_obj=`, which read the main input through cairosvg's unrestricted fetcher (any path, any URL), and `unsafe=` +(external entities and hrefs).""" +_PYGAL_SAVE_KEYWORDS: Mapping[str, frozenset[str] | None] = MappingProxyType( + { + # `render_to_png(filename=None, dpi=72, **kwargs)` and `render_to_file(filename, **kwargs)` hand their + # `**kwargs` to `render()` and from there into the chart's config (`css=`, `js=`, `defs=`), so a save takes + # none of them. + "render_to_png": frozenset({"filename", "dpi"}), + "render_to_file": frozenset({"filename"}), + "svg2png": _PYGAL_SVG2PNG_KEYWORDS, + } +) +_LETSPLOT_SAVE_KEYWORDS = frozenset({"filename", "path", "scale", "w", "h", "unit", "dpi"}) +"""`ggsave(plot, filename, *, path=None, iframe=True, scale=None, w=None, h=None, unit=None, dpi=None)`: the file, the +directory (`SaveRule.directory`) and the raster size; `iframe=` shapes only the HTML and no catalogue file passes it.""" _MATPLOTLIB_SAVE = SaveRule(names=frozenset({"savefig"}), target_keywords=frozenset({"fname"})) SAVE_RULES: Mapping[str, SaveRule] = MappingProxyType( { @@ -118,7 +174,7 @@ def allowed_keywords(self, name: str) -> frozenset[str] | None: target_keywords=frozenset({"filename", "fname"}), positions=MappingProxyType({"ggsave": 1}), keywords=MappingProxyType({"save": _PLOTNINE_SAVE_KEYWORDS, "ggsave": _PLOTNINE_SAVE_KEYWORDS}), - canvas=False, + canvas=None, ), "altair": SaveRule( names=frozenset({"save"}), @@ -126,7 +182,34 @@ def allowed_keywords(self, name: str) -> frozenset[str] | None: keywords=MappingProxyType({"save": _ALTAIR_SAVE_KEYWORDS}), html=True, reopens=True, - canvas=False, + canvas=None, + ), + # `chart.render_to_png(f"plot-{THEME}.png")` (220 of 325 catalogue files end on it), + # `cairosvg.svg2png(bytestring=chart.render(), write_to=f"plot-{THEME}.png", ...)` (92) and + # `chart.render_to_file(f"plot-{THEME}.html")`. libcairo writes the PNG with its own `fopen`, which the + # harness's audit hook never sees, so the file rests on this rule alone (survey of 2026-10-24). + "pygal": SaveRule( + names=frozenset({"render_to_png", "render_to_file", "svg2png"}), + target_keywords=frozenset({"filename", "write_to"}), + keywords=_PYGAL_SAVE_KEYWORDS, + keyword_only=frozenset({"svg2png"}), + html=True, + writes=MappingProxyType( + {"render_to_png": PNG_SUFFIX, "svg2png": PNG_SUFFIX, "render_to_file": HTML_SUFFIX} + ), + canvas="pygal", + ), + # `ggsave(plot, f"plot-{THEME}.png", path=".", scale=4)` and the same call for the HTML: every one of the 325 + # catalogue files saves both, the file as the second positional or `filename=`; 292 pass `path="."`, and the + # normaliser adds it to the other saves. + "letsplot": SaveRule( + names=frozenset({"ggsave"}), + target_keywords=frozenset({"filename"}), + positions=MappingProxyType({"ggsave": 1}), + keywords=MappingProxyType({"ggsave": _LETSPLOT_SAVE_KEYWORDS}), + html=True, + directory=".", + canvas="letsplot", ), } ) @@ -429,7 +512,11 @@ def is_standard_target(node: ast.expr) -> bool: def save_target(call: ast.Call, rule: SaveRule, name: str) -> ast.expr | None: """The file argument of the save call `name` under `rule`: its positional, else one of the rule's keywords.""" position = rule.target_position(name) - if len(call.args) > position and not any(isinstance(arg, ast.Starred) for arg in call.args[: position + 1]): + if ( + position >= 0 + and len(call.args) > position + and not any(isinstance(arg, ast.Starred) for arg in call.args[: position + 1]) + ): return call.args[position] return next((kw.value for kw in call.keywords if kw.arg in rule.target_keywords), None) diff --git a/agents/anyplot/code/validate.py b/agents/anyplot/code/validate.py index 0710c218edf..a4e6c95115f 100644 --- a/agents/anyplot/code/validate.py +++ b/agents/anyplot/code/validate.py @@ -6,8 +6,8 @@ one only the compiler reports, such as a module-level `return`), an oversize file or a string that is not UTF-8 text is itself a `Finding`. Only `library` makes `validate_security` raise `ValueError`: an id outside the catalogue, or a catalogue -library without a profile yet (`LIBRARY_IMPORTS`: matplotlib, seaborn, plotnine and -altair), because that is a caller bug, not a property of the code. +library without a profile yet (`LIBRARY_IMPORTS`: matplotlib, seaborn, plotnine, altair, +pygal and letsplot), because that is a caller bug, not a property of the code. The validator is one guardrail layer, not the boundary: the sandbox (no egress, read-only root, no environment) and the loader substitution are the others. Its job is to make the @@ -32,7 +32,29 @@ `alt.limit_rows` hands out, whose `.implementation.to_native_namespace()` is the pyarrow module; VegaFusion's `transformed_data`; and plotnine's `.environment`, the captured namespace every plot carries, whose `.namespace["os"]` is the `os` module as a value. - The four extra scipy modules, + pygal gets `pygal` and `cairosvg` (its PNG writer, which 98 catalogue files import + directly), lets-plot gets `lets_plot` (surveys of 2026-10-24, drafts and spikes of + 2026-10-26, `agentic/specs/261024-agent-all-libraries.md`, step 1c); `xml.etree`, PIL + and cairocffi stay out of pygal's surface and PIL out of lets-plot's, so the catalogue + files that post-process the SVG or composite the PNG stay blocked. libcairo writes + pygal's PNG with its own `fopen`, which the render guard never sees, so the closures + matter more there: cairosvg's modules that read any path or URL or write any file + (`parser`, `surface`, `url`, `image`, `css`, `features`, `test_api`, the re-exported + `Path` and the `ROOT` instance, banned on their module in `MODULE_ESCAPES`), cairocffi + reached as `.cairo` with its `ffi`, `dlopen` and file surfaces, cairosvg's other + writers (`svg2pdf`, `svg2ps`, `svg2eps`, `svg2svg`, `SURFACES`) and fetchers, and + pygal's renderers that open a browser or hand out the live tree, its `getattr` by + string (`cut`), its `str.format` (`template`) and its config merge (`_update`), all in + pygal's `LIBRARY_BANS`; `etree`, pygal's ElementTree proxy, is banned on every + receiver (`UNSAFE_SUBMODULES`), and so are `cssselect2`, `tinycss2`, `webencodings` and + `cairocffi` (`ESCAPE_ATTRIBUTES`): cssselect2 `eval`s the selectors it compiles, and a + forged token object handed to its compiler reaches an unquoted slot of that code (the + pygal spike of 2026-10-26). lets-plot's geocoding, map tiles, settings and browser + launcher are denied imports and banned submodules, its writers (`to_png`, `to_svg`, + `to_pdf`), browser hook (`setup_show_ext`), import-by-name proxy (`LazyModule`) and + geocoders are `UNSAFE_HELPERS`, the proxies it hands out (`png`, `palettable`, + `geopandas`) `ESCAPE_ATTRIBUTES`, and `LetsPlot.set`, `LetsPlot.setup_show_ext` and + `lets_plot.cfg` `MODULE_ESCAPES`. The four extra scipy modules, `statsmodels.graphics` and the toolkits were admitted after the readiness sweep showed them behind most of the catalogue's benign import blocks; each was walked for file or network helpers and has none. The pure computing modules of the standard library @@ -109,7 +131,8 @@ URLs; `.io` and `ExcelWriter` are added for `pandas.io.common.get_handle` and the Excel writer, and `open_memmap`, `imsave`, `to_filehandle`, `open_file_cm`, `rc_params_from_file` and the rarer pandas writers for the remaining file paths of - numpy, matplotlib and pandas; the design's list names none of them. + numpy, matplotlib and pandas; the design's list names none of them. The one exception + is lets-plot's spec builders ("lets-plot" below). * **Import paths are checked component by component** against the same name sets, so an alias cannot drop a banned component: `import pandas.io.common as common` and `import numpy.lib.format as fmt` are findings although `common.get_handle` and @@ -122,8 +145,11 @@ by `import os [as X]` must be the receiver of `X.getenv("ANYPLOT_THEME", ...)` or `X.environ.get("ANYPLOT_THEME", ...)` with the literal first argument, at most two positional arguments and no keyword other than `default`. `from os import ...` is a - banned import. Note `__file__` is a dunder, so the catalogue's `sys.path` guard fails - twice (`sys` and `__file__`); the normaliser strips that guard before eligibility. + banned import, and nothing else binds a name `import os` binds (`def os` under + `functools.wraps`, `import textwrap as os`, an `except` or `match` capture, a + parameter): each would hand the theme block another object's `getenv`. Note `__file__` + is a dunder, so the catalogue's `sys.path` guard fails twice (`sys` and `__file__`); the + normaliser strips that guard before eligibility. * **Dunders and strings.** Every identifier matching `^__\\w+__$` (names, attributes, keywords, parameters, function names) and every string or bytes literal containing a dunder identifier is a finding; `__name__ == "__main__"` guards therefore fail, which @@ -133,21 +159,50 @@ * **URL literals** match `http://`, `https://`, `ftp://`, and `file:`/`data:` followed by a non-space character, case-insensitively and at a word boundary, so a subtitle "Source data: 2024 survey" or the word "profile:" passes while `data:image/png;...` - and `file:///etc/passwd` do not. + and `file:///etc/passwd` do not. pygal's profile lets two kinds pass: the SVG and XLink + namespace names, exactly `http://www.w3.org/2000/svg` and + `http://www.w3.org/1999/xlink`, also in Clark notation (`{...}rect`), followed by a + quote, `}`, whitespace or the end of the string (an XML parser compares them and never + fetches them; 31 catalogue files write or match SVG markup), and pygal's own + stylesheets in a `css=` list ("pygal's config" below). * **The save (`savefig-target`).** Every call of a save name (`regions.SAVE_NAMES`: - `savefig`, `save`, `ggsave`), not only the last, is checked against the library's save - rule (`regions.SAVE_RULES`). A name the rule does not list is a `banned-call` (`np.save` - with matplotlib, `fig.savefig` with altair). The file argument (the rule's positional, - `fname=`, `filename=` or `fp=`; `ggsave` takes the plot first and the file second) must - be exactly the f-string `f"plot-{THEME}.png"`, for altair also `f"plot-{THEME}.html"`, - the second file its catalogue writes; `format=` is absent or the file's own type; there - are no `**arguments`; and plotnine's `save` and `ggsave` and altair's `save` take only - the keywords the rule lists (the size, the dpi, altair's `scale_factor`), so `path=`, - which joins a directory, `webdriver=` and `engine=`, which choose how altair makes the - PNG, are findings. A file without a save of the PNG is a finding too; a second save - writing elsewhere is never wanted. A save name may only be called where it is named + `savefig`, `save`, `ggsave`, `render_to_png`, `render_to_file`, `svg2png`), not only the + last, is checked against the library's save rule (`regions.SAVE_RULES`). A name the rule + does not list is a `banned-call` (`np.save` with matplotlib, `fig.savefig` with altair, + PIL's `.save` with pygal). The file argument (the rule's positional, `fname=`, + `filename=`, `fp=` or `write_to=`; `ggsave` takes the plot first and the file second, + `cairosvg.svg2png` takes every argument by keyword) must be exactly the f-string + `f"plot-{THEME}.png"`, for altair, pygal and lets-plot also `f"plot-{THEME}.html"`, + the second file their catalogues write, and a pygal save writes its own kind of file + (`SaveRule.writes`: `render_to_png` and `svg2png` the PNG, `render_to_file`, which + writes SVG text, the HTML); `format=` is absent or the file's own type; + there are no `**arguments`; and the saves of plotnine, altair, pygal and lets-plot take + only the keywords the rule lists (the size, the dpi, altair's `scale_factor`, svg2png's + input and output size), so `path=` with plotnine, which joins a directory, `webdriver=` + and `engine=`, which choose how altair makes the PNG, a pygal save's chart options and + svg2png's `url=`, `file_obj=` and `unsafe=`, which read any path or URL, are findings. + lets-plot's `ggsave` passes exactly `path="."` (`SaveRule.directory`): without it, + lets-plot writes into a `lets-plot-images` directory it creates. A file without a save + of the PNG is a finding too, and so is a save without a file (pygal's + `render_to_png()` returns bytes) or with a file object; a second save writing + elsewhere is never wanted. A save name may only be called where it is named (`f = plot.save; f("x")` would hide the target), and so may `.open`, which only altair's rule admits, on the PNG (the PIL block reopens it: `Image.open(f"plot-{THEME}.png")`). + A save name is never imported under another name (`from plotnine import ggsave as + keep`), nothing is imported under a save name, and a bare save name is only ever the + callee of a call (`g = ggsave`, `[ggsave][0](...)` and `partial(ggsave, plot)` are + `banned-call` findings in every profile): the rule matches calls by their callee's + name, so a save reached under another name would write anywhere (the lets-plot draft + of 2026-10-26 showed it in the plotnine profile). `THEME` keeps the value of the theme + block, because every file name above is built from it and libcairo writes pygal's PNG + where the render guard never looks (the security review of 2026-10-26): the one + binding of the name is the first module-level `THEME = os.getenv("ANYPLOT_THEME", ...)` + (the shapes `regions.is_theme_assignment` accepts, after `import os`), and every other + is a `savefig-target` finding where it stands: a second, augmented or annotated + assignment, a `for`, `with`, `except`, `match`, walrus or comprehension target, an + import, a `def`, a parameter, a type alias or parameter, a `del`, and a `global` or + `nonlocal` declaration (also a `banned-statement`). plotnine's expression strings need + no such rule: their assignments land in plotnine's own lookup dict, never in the module. * **Expression strings (plotnine).** plotnine compiles and evaluates a string that is not a column name wherever it takes an aesthetic or a facet (`aes`, `after_stat`, `after_scale`, `stage`, `facet_wrap`, `facet_grid`, `qplot`), with real builtins and the @@ -168,6 +223,37 @@ string to patsy, which `exec`s Python while it parses, and the render guard refuses that. The guard lets plotnine's `Environment.eval` run the walked strings (`harness.CODE_BUILDERS`); what they reach stays under the guard. +* **pygal's config.** A `css` or `js` entry that starts with `file://` is read from any + path and inlined into the SVG, any other entry but `inline:...` becomes a stylesheet or + script the HTML fetches, `defs` is raw SVG markup, and a `googlefont:` font makes the + SVG load Google Fonts (pygal 3.1.3, surveyed 2026-10-24). So `js=` takes only an empty + list or tuple (30 catalogue files pass `js=[]`), `css=` takes a list or tuple of + `inline:` texts (a string or an f-string whose text starts with it) and pygal's own + `file://style.css`, `file://graph.css` and `file://base.css`, `defs` is refused, all + three as keywords on any call and as `.css = [...]` and `.js = [...]` assignments; a + list or a text may be a name bound once at module level, used nowhere but where the + rule reads it. The attributes are never read, augmented, subscripted or deleted, so a + list cannot change after the check (`chart.css += [...]`, `chart.css.append(...)`), and + every `**` argument is a dict literal with string keys, or a name bound once at module + level to one and used only as `**name` (3 catalogue files share their chart options + that way), whose `css`, `js` and `defs` keys are checked the same way. A string that + contains `googlefont:` is a `url-literal`. Findings are `banned-keyword` for a value and + `banned-attribute` for an attribute. +* **lets-plot.** `LetsPlot` is used only as `LetsPlot.setup_html()` without arguments + (every one of the 325 catalogue files; `set` points the HTML's script at any URL and + `setup_show_ext` starts a browser), and `.props`, which hands out a layer's spec dict + for rewriting after the check (an image layer's `href` included), is banned in its + profile (`LIBRARY_BANS`; the spike of 2026-10-26). The `.format` ban has one exception + here: lets-plot's tooltip and label builders set a field's format with `.format(field, + format)` (128 uses in 59 catalogue files), so `.format` passes when its receiver is a + chain of builder method calls (`LETSPLOT_BUILDER_METHODS`, each of which returns the + builder) rooted in a call of `layer_tooltips`, `layer_labels` or `smooth_labels` + imported with `from lets_plot import ...`, bound nowhere else and never assigned as an + attribute (`lets_plot.layer_tooltips = str` before the import), and when it takes only + string literals (positional, or `field=` and `format=`). `"{0}".format(...)`, a builder + bound to a name (`tt = layer_tooltips(); tt.format(...)`) and a builder name bound + twice stay findings. The `format=` keyword (`scale_y_continuous(format=...)`) is + no attribute and passes everywhere. * **`type()`** is allowed with exactly one plain positional argument; anything else (three arguments, starred arguments, keywords) is the class-creating form. * **String method names.** pandas resolves `df.agg("to_csv")`, `df.apply("eval")` and @@ -235,7 +321,8 @@ Rule ids: `size`, `encoding`, `syntax`, `banned-import` (with `Finding.module`), `star-import`, `relative-import`, `os-use`, `module-value`, `banned-name`, `banned-call`, `banned-attribute`, `banned-keyword`, `rc-key`, `dunder`, `banned-statement`, `url-literal`, `string-length`, `savefig-target` -(SECURITY; DATA without `savefig-target`; a plotnine expression string's finding keeps the id of the rule it broke); +(SECURITY; DATA without `savefig-target`; a plotnine expression string's finding keeps the id of the rule it broke; +pygal's config and lets-plot's rules use the ids above, the THEME pin `savefig-target`); `placeholder-count`, `placeholder-use`, `rng`, `literal-data`, `palette-prefix` (ADAPTATION); `data-frame` (DATA). Findings are sorted by line; messages never echo more than about 60 characters of code and never echo string literal contents. @@ -247,7 +334,7 @@ import re import sys from collections import defaultdict -from collections.abc import Container, Iterable +from collections.abc import Callable, Container, Iterable from dataclasses import dataclass from typing import Literal, TypeVar @@ -255,7 +342,17 @@ from core.palette import IMPRINT from ..schemas import MAX_CODE_CHARS -from .regions import HTML_SUFFIX, SAVE_NAMES, SaveRule, is_theme_target, save_rule, save_target +from .regions import ( + HTML_SUFFIX, + PNG_SUFFIX, + SAVE_NAMES, + THEME_NAME, + SaveRule, + is_theme_assignment, + is_theme_target, + save_rule, + save_target, +) Profile = Literal["security", "adaptation", "data"] @@ -269,6 +366,10 @@ MAX_SNIPPET_CHARS = 60 PLACEHOLDER = "df = load_user_data()" THEME_VARIABLE = "ANYPLOT_THEME" +THEME_BINDING_MESSAGE = ( + 'THEME may only be bound by the theme block, THEME = os.getenv("ANYPLOT_THEME", ...) after import os: ' + 'every save writes f"plot-{THEME}..."' +) # The pure standard modules from `warnings` on were admitted by the owner (2026-10-10). # Each was walked for public names that read, load, save, open, fetch or evaluate, and @@ -381,6 +482,14 @@ "altair.utils.execeval", "altair.utils.server", "altair.utils.html", + # lets-plot (survey of 2026-10-24): `geo_data` geocodes over urllib, `tilesets`, `settings_utils` and the + # livemap geom put tile and geocoder URLs into the spec, `frontend_context` writes a temporary HTML file and + # starts a browser. + "lets_plot.geo_data", + "lets_plot.tilesets", + "lets_plot.settings_utils", + "lets_plot.frontend_context", + "lets_plot.plot.geom_livemap_", } ) # The three toolkits ship with matplotlib and do no I/O. Third-party plotting helpers @@ -392,13 +501,19 @@ # One profile per Python library with a save rule (`regions.SAVE_RULES`): plotnine draws # with matplotlib (5 catalogue files import it directly) and scales with mizani; altair's # catalogue pads the PNG with `PIL.Image` (the module, not the package: `ImageDraw`, -# `ImageFont` and the rest stay out). The other Python libraries (plotly, bokeh, pygal, -# lets-plot) have no profile yet and raise `ValueError`. +# `ImageFont` and the rest stay out); pygal renders its PNG through cairosvg, which 98 +# catalogue files also import directly (`cairocffi`, `PIL`, `xml.etree` and matplotlib stay +# out, and so do the 23 catalogue files that subclass pygal's `Graph`: a class definition +# is refused in every profile); lets-plot needs nothing else (no catalogue file imports +# matplotlib with it; the 4 that draw on the PNG with PIL stay out). The other Python +# libraries (plotly, bokeh) have no profile yet and raise `ValueError`. LIBRARY_IMPORTS: dict[str, frozenset[str]] = { "matplotlib": _MATPLOTLIB_STACK, "seaborn": _MATPLOTLIB_STACK | {"seaborn"}, "plotnine": _MATPLOTLIB_STACK | {"plotnine", "mizani"}, "altair": frozenset({"altair", "PIL.Image"}), + "pygal": frozenset({"pygal", "cairosvg"}), + "letsplot": frozenset({"lets_plot"}), } # The DATA profile: no plotting stack, no sklearn (its `datasets` fetch over the network) and # no statsmodels (`statsmodels.api.datasets` and `webuse` read files and URLs). The standard @@ -706,6 +821,18 @@ "vl_convert", "pyarrow", "polars", + # cairosvg's `css` and `parser` hand out cssselect2, whose selector compiler `eval`s the code it builds: a + # forged token object reaches an unquoted slot of it (the pygal spike of 2026-10-26); tinycss2 and + # webencodings are its parser, cairocffi the C binding behind `.cairo` (survey of 2026-10-24). + "cssselect2", + "tinycss2", + "webencodings", + "cairocffi", + # lets-plot's `LazyModule` proxies import their module by name on the first attribute access: + # `geom_imshow_.png.Reader(filename=...)` reads any file (survey of 2026-10-24, its refute pass). + "geopandas", + "png", + "palettable", } ) UNSAFE_SUBMODULES: frozenset[str] = frozenset( @@ -737,13 +864,24 @@ "server", # altair.utils.server opens a port and a browser; the attribute is the stdlib http.server "html", # altair.utils.html: Jinja templates built from text (`environment.from_string`) "jupyter", # altair.jupyter re-exports importlib's `find_spec` + # pygal and cairosvg (surveys of 2026-10-24): pygal's ElementTree proxy, re-exported as `pygal.svg.etree` and + # `pygal.graph.map.etree` (`parse`, `ElementTree(root).write(path)`); cairosvg's pytest module. + "etree", + "test_api", + # lets-plot (survey of 2026-10-24): the browser launcher, geocoding, tile and geocoder URLs, the livemap geom. + "frontend_context", + "geo_data", + "settings_utils", + "tilesets", + "geom_livemap_", } ) """Submodules of the allowed packages that test, build, start processes, run LaTeX or import by name (validator review of 2026-10-14: `np.testing.measure` compiles and execs its string). Banned like `ESCAPE_ATTRIBUTES`: as an attribute on any object, as a `from` import and as an import path component below its root. A test pins that every -public first-level submodule of numpy, pandas, matplotlib, seaborn and scikit-learn is either reviewed as safe or -in this set, so a library upgrade that adds one fails until someone looks at it.""" +public first-level submodule of numpy, pandas, matplotlib, seaborn, scikit-learn and the plotting libraries is +either reviewed as safe or banned by name (here, or on its own module in `MODULE_ESCAPES`), so a library upgrade +that adds one fails until someone looks at it.""" UNSAFE_HELPERS: frozenset[str] = frozenset( { "test", # np.test, pd.test, mpl.test, scipy's and statsmodels' `test()` run pytest @@ -836,6 +974,33 @@ "namespaces", "qplot", "from_formula", + # lets-plot (survey of 2026-10-24): the writers next to `ggsave` (`to_html` is in `BANNED_IO`, `show` in + # `BANNED_ATTRIBUTES`), the browser hook and its page context, import by name (`LazyModule("subprocess")`), + # the livemap geom and its tile configs, the geocoders and the requests they send, and `pkgutil.extend_path` + # left on the package root. No catalogue file of another library uses any of these names. + "to_png", + "to_svg", + "to_pdf", + "setup_show_ext", + "WebBrHtmlPageContext", + "LazyModule", + "geom_livemap", + "maptiles_zxy", + "maptiles_lets_plot", + "maptiles_solid", + "maptiles_chessboard", + "geocoding_service", + "geocode", + "geocode_cities", + "geocode_counties", + "geocode_states", + "geocode_countries", + "reverse_geocode", + "get_geocodes", + "get_centroids", + "get_boundaries", + "get_limits", + "extend_path", } ) """Public functions and classes of the allowed modules that exec, start a process, import by name, fetch or write @@ -857,6 +1022,26 @@ "altair.vegalite.v6.display.Path", "altair.version", "mizani.version", + # cairosvg (surveys of 2026-10-24): `parser` reads the main input from any path or URL with an unrestricted + # fetcher, `surface` writes any path through libcairo and hands out cairocffi, `url` is urlopen, `image` + # hands out PIL's `Image` and `ImageOps`, `css` cssselect2, `features` the locale module, `test_api` runs + # pytest; `Path` is pathlib's class and `ROOT` a `Path` instance (the package directory). Each name is + # plausible elsewhere (`matplotlib.image`, `matplotlib.path.Path`, a `url` column). + "cairosvg.Path", + "cairosvg.ROOT", + "cairosvg.parser", + "cairosvg.surface", + "cairosvg.url", + "cairosvg.image", + "cairosvg.css", + "cairosvg.features", + "cairosvg.test_api", + # lets-plot: `LetsPlot.set` points the HTML's script and the geocoder at any URL, `setup_show_ext` installs + # the browser launcher, `cfg` is the private configuration module under a public name (the survey's refute + # pass: banned by path, because `cfg` is a common name). + "lets_plot.LetsPlot.set", + "lets_plot.LetsPlot.setup_show_ext", + "lets_plot.cfg", } ) """Escapes banned only on their own module, because the bare name is plausible data: `calendar.main` @@ -884,6 +1069,7 @@ "vl_convert": "altair writes the PNG itself through chart.save(); import nothing from vl_convert", "plotnine.data": _DATA_FROM_DF, "altair.datasets": _DATA_FROM_DF, + "lets_plot.geo_data": _DATA_FROM_DF, } """What a `banned-import` repair line suggests instead, by module path or its longest known prefix.""" DEFAULT_IMPORT_ALTERNATIVE = ( @@ -901,6 +1087,10 @@ "qrcode", "squarify", "wordcloud", + "pygal_maps_world", + "cairocffi", + "cssselect2", + "tinycss2", "altair", "bokeh", "highcharts_core", @@ -961,7 +1151,9 @@ _RC_FILE_READERS = frozenset({"rc_context", "theme_matplotlib"}) """`rc_context(rc, fname)` and plotnine's `theme_matplotlib(rc, fname)` load every setting from the file `fname` names (`rc_params_from_file`), whose keys the string rule never sees: no second positional, no `fname=`.""" -BANNED_KEYWORDS = frozenset({"usetex", "backend", "memory", "temp_folder", "cachedir", "formula", "url"}) +BANNED_KEYWORDS = frozenset( + {"usetex", "backend", "memory", "temp_folder", "cachedir", "formula", "url", "file_obj", "unsafe", "url_fetcher"} +) """Keyword arguments banned on any call: `usetex=True` sends a text through LaTeX (`ax.text`, `TextPath`, `plt.rc`), `backend=` imports a backend module by name (`savefig(backend="module://...")`). `memory=` is a joblib cache directory that scikit-learn writes fitted steps to (`Pipeline`, `make_pipeline`, `AgglomerativeClustering`, @@ -974,14 +1166,67 @@ `exec`s Python while it parses it; the render guard refuses that `exec`, so the keyword is refused first (no matplotlib or seaborn catalogue file uses it). `url=` names an image mark's href in altair (`encode(url=...)`, `MarkDef(url=...)`), which vl_convert reads from disk for a `file://` value, and matplotlib artists' SVG hyperlink, -which a PNG never carries (one catalogue file, altair's image mark, uses it).""" +which a PNG never carries (one catalogue file, altair's image mark, uses it); cairosvg's `svg2png` reads its main +input from any path or URL with `url=` (2 pygal catalogue files) and from a file object with `file_obj=`, +`unsafe=True` lets the SVG's entities and hrefs read files and fetch, and `url_fetcher=` replaces the fetcher that +keeps hrefs to `data:` URLs (survey of 2026-10-24; no catalogue file passes any of the three).""" LIBRARY_BANS: dict[str, frozenset[str]] = { # `Chart.from_dict` and `from_json` build a chart from a spec the string rules cannot read (an image mark with # a `file://` href); pandas' `DataFrame.from_dict` is ordinary with the other libraries, so the ban is altair's. # `data_transformers.enable("json")` and `enable("csv", filename=...)` make every save write the chart's data # to a file, and `.register(name, fn)` with `.enable(name)` is the plugin hook of every altair registry # (renderers, themes, compilers); the catalogue only calls `data_transformers.disable_max_rows()`. - "altair": frozenset({"from_dict", "from_json", "enable", "register"}) + "altair": frozenset({"from_dict", "from_json", "enable", "register"}), + # pygal and cairosvg (surveys of 2026-10-24, the pygal draft of 2026-10-26): pygal's renderers that open a + # browser or import pyquery, lxml, flask or django, and the live ElementTree (`render_tree`, `add_xml_filter`, + # `xml_filters`); `pygal.util.cut`, an unchecked `getattr` by string (`cut([obj], "ctypes")`), and `template`, + # `str.format` on a caller string; the plugin entry points and their import hook; `Config._update`, which merges + # any dict into a chart's options past the `css`/`js` rule. cairosvg's other writers and its surfaces, which + # libcairo opens natively, its parser and fetchers; cairocffi as `.cairo`, its `ffi`, `dlopen` and + # `find_library` (which runs ldconfig and gcc), the pycairo shim and the file readers and writers of its + # surfaces. `cut` is pandas' binning function with the other libraries, the rest are plausible names there. + "pygal": frozenset( + { + "render_in_browser", + "render_pyquery", + "render_table", + "render_response", + "render_django_response", + "render_tree", + "add_xml_filter", + "xml_filters", + "cut", + "template", + "entry_points", + "PluginImportFixer", + "_update", + "svg2pdf", + "svg2ps", + "svg2eps", + "svg2svg", + "SURFACES", + "Tree", + "fetch", + "safe_fetch", + "read_url", + "fetch_url", + "cairo", + "ffi", + "dlopen", + "find_library", + "install_as_pycairo", + "write_to_png", + "create_from_png", + "PDFSurface", + "PSSurface", + "SVGSurface", + "PNGSurface", + "EPSSurface", + } + ), + # `LayerSpec.props()` hands out the spec's own dict, so code could rewrite a field the native bridge reads (an + # image layer's `href`) after the check (the lets-plot spike of 2026-10-26); `props` is a plausible name elsewhere. + "letsplot": frozenset({"props"}), } """Names banned as attributes and `from` imports in one library's profile only, like `UNSAFE_HELPERS` there.""" _STRING_DISPATCH_METHODS = frozenset({"agg", "aggregate", "apply", "transform", "applymap"}) @@ -995,6 +1240,37 @@ """The profiles that check `EXPRESSION_CALLS`.""" _FORMULA_SPLIT = re.compile(r"[~+]") """A facet formula's sides and terms (`"pane ~ ."`, `"a + b ~ c"`), which plotnine evaluates one by one.""" +PYGAL_CONFIG_KEYS = frozenset({"css", "js", "defs"}) +"""pygal's chart options that read files or embed markup (module docstring, "pygal's config").""" +PYGAL_PACKAGE_CSS = frozenset({"file://style.css", "file://graph.css", "file://base.css"}) +"""pygal's own stylesheets (`Config.css` defaults and `base.css`), which a `css=` list repeats before its `inline:` +rules (14 catalogue files); pygal reads them from its package's `css` directory.""" +_INLINE_CSS = "inline:" +_GOOGLE_FONT = "googlefont:" +_SVG_NAMESPACE = re.compile(r"\{?http://www\.w3\.org/(?:2000/svg|1999/xlink)(?=[\"'}\s]|$)") +"""The SVG and XLink namespace names, also in Clark notation (`{...}rect`), followed by a quote, `}`, whitespace or the +end; an XML parser compares them and never fetches them, so pygal's URL rule drops them before it looks.""" +LETSPLOT_BUILDERS = frozenset({"lets_plot.layer_tooltips", "lets_plot.layer_labels", "lets_plot.smooth_labels"}) +"""lets-plot's tooltip and label builders, whose `.format(field, format)` sets a spec field (module docstring).""" +LETSPLOT_BUILDER_METHODS = frozenset( + { + "line", + "format", + "title", + "anchor", + "min_width", + "disable_splitting", + "color", + "size", + "inherit_color", + "eq", + "label_x", + "label_y", + } +) +"""The builders' methods, each of which returns the builder itself (lets-plot 4.11.0, `plot/tooltip.py` and +`plot/annotation.py`), so a chain of them never reaches a string's `format`.""" +_LETSPLOT_OBJECT = "lets_plot.LetsPlot" _DIRECT_CALL_ONLY = SAVE_NAMES | {"open"} """Attributes that may only be called where they are named, so the save rule sees their target.""" # Methods that return text without a target and write to one given as `buf`, with the position of @@ -1226,6 +1502,16 @@ def _is_none(node: ast.expr) -> bool: return isinstance(node, ast.Constant) and node.value is None +def _is_text(node: ast.expr) -> bool: + return isinstance(node, ast.Constant) and isinstance(node.value, str) + + +def _is_inline_css(node: ast.expr) -> bool: + """A pygal `css` entry that pygal inlines as CSS text: a string or an f-string whose literal start is `inline:`.""" + head = node.values[0] if isinstance(node, ast.JoinedStr) and node.values else node + return isinstance(head, ast.Constant) and isinstance(head.value, str) and head.value.startswith(_INLINE_CSS) + + def _text(node: ast.Constant) -> str | None: """The textual content of a string or bytes literal, or None for other constants.""" if isinstance(node.value, str): @@ -1361,6 +1647,10 @@ def __init__(self, tree: ast.Module, *, data: bool = False) -> None: self.parents[child] = node self.findings: list[Finding] = [] self.os_names: set[str] = set() + self.library = "" + """The profile's library id; empty in the DATA profile.""" + self.url_exempt: set[ast.AST] = set() + """String literals the URL rule lets pass: pygal's own stylesheets in a `css=` list (`_check_pygal_config`).""" self.allowed_imports: frozenset[str] = frozenset() self.library_bans: frozenset[str] = frozenset() """`LIBRARY_BANS` of the profile's library: banned as attributes and `from` imports in this scan.""" @@ -1449,6 +1739,7 @@ def _is_call_receiver(self, node: ast.AST, methods: frozenset[str]) -> bool: # -- SECURITY ------------------------------------------------------------------------ def security(self, library: str, allowed_imports: frozenset[str], rule: SaveRule) -> None: + self.library = library self.library_bans = LIBRARY_BANS.get(library, frozenset()) self.library_imports = LIBRARY_IMPORTS.get(library, frozenset()) self._check_imports(library, allowed_imports, rule) @@ -1462,8 +1753,14 @@ def security(self, library: str, allowed_imports: frozenset[str], rule: SaveRule self._check_settings() self._check_buffer_writers() self._check_statements() + if library == "pygal": + self._check_pygal_config() # before the strings: it marks pygal's own stylesheets for the URL rule + if library == "letsplot": + self._check_letsplot_object() self._check_strings() self._check_save(rule) + self._check_save_names() + self._check_theme_bindings() if library in EXPRESSION_LIBRARIES: self._check_expression_strings() @@ -1485,6 +1782,7 @@ def data(self, allowed_imports: frozenset[str]) -> None: self._check_buffer_writers() self._check_statements() self._check_save(None) + self._check_save_names() self._check_strings() self._check_data_frame() self._check_data_literals() @@ -1638,6 +1936,16 @@ def _check_os_use(self) -> None: for name in self.of(ast.Name): if name.id in self.os_names and not self._is_theme_lookup(name): self.add("os-use", f"'{name.id}' may only be used as {name.id}.getenv('{THEME_VARIABLE}', ...)", name) + # A store of the name is a `Name` above; `def os(): ...` under `@functools.wraps(g, assigned=("getenv",))` or + # `from textwrap import dedent as os` would hand the theme block another object's `getenv`. + for variable in sorted(self.os_names): + for node in self._binding_nodes(variable): + if not isinstance(node, ast.Name) and not self._is_os_import(node): + self.add("os-use", f"'{variable}' may only be bound by import os", node) + + def _is_os_import(self, node: ast.AST) -> bool: + """`node` is the alias of an `import os [as X]`.""" + return isinstance(node, ast.alias) and node.name == "os" and isinstance(self.parents.get(node), ast.Import) def _check_module_values(self) -> None: """A name bound by `import X [as Y]` only as the receiver of an attribute (module docstring). @@ -1706,6 +2014,8 @@ def _check_attributes(self) -> None: for attribute in self.of(ast.Attribute): if _DUNDER_NAME.match(attribute.attr): self.add("dunder", f"dunder attribute '.{attribute.attr}' is not allowed", attribute) + elif attribute.attr == "format" and self.library == "letsplot" and self._is_builder_format(attribute): + continue # lets-plot's tooltip and label builders (module docstring, "lets-plot") elif ( attribute.attr in BANNED_ATTRIBUTES or attribute.attr in _ANY_RECEIVER_ESCAPES @@ -1940,7 +2250,11 @@ def _check_strings(self) -> None: self.add("string-length", message, constant) if _DUNDER_IN_TEXT.search(text): self.add("dunder", "string literal contains a dunder identifier", constant) - if _URL_IN_TEXT.search(text): + pygal = self.library == "pygal" + if pygal and isinstance(constant.value, str) and _GOOGLE_FONT in text: + self.add("url-literal", "string literal names a Google font, which the chart fetches", constant) + urls = _SVG_NAMESPACE.sub("", text) if pygal else text + if _URL_IN_TEXT.search(urls) and constant not in self.url_exempt: hint = f" → {DATA_IO_MESSAGE}" if self.data_profile else "" self.add("url-literal", f"string literal contains a URL or a file:/data: scheme{hint}", constant) if isinstance(constant.value, str) and _is_unsafe_setting(text): @@ -1985,16 +2299,33 @@ def _check_save(self, rule: SaveRule | None) -> None: self.add("savefig-target", f'no {rule.verb} of f"plot-{{THEME}}.png" found', None) def _check_save_call(self, call: ast.Call, rule: SaveRule, name: str) -> bool: - """The file, the format and the keywords of one save call; whether it writes the PNG.""" + """The file, the format and the keywords of one save call; whether it writes the PNG. + + A save of the PNG by a name that writes only the HTML (`render_to_file(f"plot-{THEME}.png")` writes SVG text + into the PNG) writes no PNG, so a file with no other PNG save also gets the missing-save finding. + """ target = save_target(call, rule, name) png = target is not None and _is_theme_target(target) html = rule.html and target is not None and is_theme_target(target, HTML_SUFFIX) if not (png or html): files = 'f"plot-{THEME}.png"' + (' or f"plot-{THEME}.html"' if rule.html else "") self.add("savefig-target", f"{name} target must be exactly {files}", call) - if len(call.args) > rule.target_position(name) + 1: + elif not rule.writes_suffix(name, PNG_SUFFIX if png else HTML_SUFFIX): + # pygal's names each write one kind of file (`SaveRule.writes`; the correctness review of 2026-10-26). + self.add("savefig-target", f'{name} writes only f"plot-{{THEME}}{rule.writes[name]}"', call) + png = False + position = rule.target_position(name) + if position < 0 and call.args: + # `svg2png(bytestring, ...)`: the keyword-only save names its file with `write_to=` alone. + self.add("savefig-target", f"{name} takes every argument by keyword", call) + elif len(call.args) > position + 1: # `plot.save(f, None, "/tmp")` is `path=`, `plot.save(f, "pgf")` is `format=`, by position. self.add("savefig-target", f"{name} takes only the file by position; the rest go by keyword", call) + if rule.directory is not None: + # lets-plot's `ggsave` writes into `./lets-plot-images`, which it creates, unless `path=` names a directory. + directory = next((keyword.value for keyword in call.keywords if keyword.arg == "path"), None) + if not (isinstance(directory, ast.Constant) and directory.value == rule.directory): + self.add("savefig-target", f'{name} must save with path="{rule.directory}"', call) allowed = rule.allowed_keywords(name) for keyword in call.keywords: # `format="ps"` with a distiller or `format="pgf"` would start Ghostscript or LaTeX, whatever the name. @@ -2010,6 +2341,46 @@ def _check_save_call(self, call: ast.Call, rule: SaveRule, name: str) -> bool: self.add("savefig-target", f"{name} takes no *arguments", call) return png + def _check_save_names(self) -> None: + """A save name keeps its own name and is reached only by calling it (module docstring, "The save"). + + The save rule finds a save by its callee's name, so `from plotnine import ggsave as keep`, `g = ggsave`, + `[ggsave][0](...)` and `partial(ggsave, plot)` would each write wherever their arguments say; `.save` and the + other save attributes have the same rule in `_check_attributes`. + """ + for from_node in self.of(ast.ImportFrom): + for alias in from_node.names: + bound = alias.asname or alias.name + if bound == alias.name: + continue + if alias.name in SAVE_NAMES: + self.add("banned-call", f"'{alias.name}' may only be imported under its own name", alias) + elif bound in SAVE_NAMES: + self.add("banned-call", f"nothing may be imported under the save name '{bound}'", alias) + for name in self.of(ast.Name): + if name.id in SAVE_NAMES and isinstance(name.ctx, ast.Load): + parent = self.parents.get(name) + if not (isinstance(parent, ast.Call) and parent.func is name): + self.add("banned-call", f"'{name.id}' may only be called directly", name) + + def _check_theme_bindings(self) -> None: + """`THEME` keeps the value of the theme block (module docstring, "The save"). + + The one binding allowed is the target of the first module-level `THEME = os.getenv("ANYPLOT_THEME", ...)` + (`regions.is_theme_assignment`), and only after `import os`; every other binding of the name, and a `global` or + `nonlocal` declaration of it, is a finding where it stands. + """ + block = next((statement for statement in self.tree.body if is_theme_assignment(statement)), None) + allowed: ast.AST | None = None + if "os" in self.os_names and isinstance(block, ast.Assign): + allowed = block.targets[0] + elif "os" in self.os_names and isinstance(block, ast.AnnAssign): + allowed = block.target + declarations: list[ast.Global | ast.Nonlocal] = [*self.of(ast.Global), *self.of(ast.Nonlocal)] + for node in [*self._binding_nodes(THEME_NAME), *(node for node in declarations if THEME_NAME in node.names)]: + if node is not allowed: + self.add("savefig-target", THEME_BINDING_MESSAGE, node) + def _check_expression_strings(self) -> None: """plotnine's expression arguments: literals the walk can read, each string walked as code (module docstring, "Expression strings").""" @@ -2063,16 +2434,56 @@ def _check_expression_argument(self, node: ast.expr, name: str) -> None: def _module_string(self, variable: str) -> ast.Constant | None: """The string literal a module-level `variable = "..."` binds, when nothing else binds `variable`.""" - stores = [name for name in self.of(ast.Name) if name.id == variable and isinstance(name.ctx, ast.Store)] - if len(stores) != 1 or any(parameter.arg == variable for parameter in self.of(ast.arg)): + value = self._module_value(variable) + return value if isinstance(value, ast.Constant) and isinstance(value.value, str) else None + + def _module_value(self, variable: str, read: Callable[[ast.Name], bool] | None = None) -> ast.expr | None: + """The value a module-level `variable = value` binds, when nothing else in the file binds `variable`; with + `read`, only when every read of the name passes it too (a list or a dict nothing can change after the check). + """ + if self._bindings(variable) != 1: return None - for stmt in self.tree.body: - target = _single_name_target(stmt) - if isinstance(stmt, ast.Assign) and target is not None and target.id == variable: - value = stmt.value - return value if isinstance(value, ast.Constant) and isinstance(value.value, str) else None + for statement in self.tree.body: + target = _single_name_target(statement) + if not isinstance(statement, ast.Assign | ast.AnnAssign) or target is None or target.id != variable: + continue + reads = [name for name in self.of(ast.Name) if name.id == variable and isinstance(name.ctx, ast.Load)] + if read is not None and not all(read(name) for name in reads): + return None + return statement.value return None + def _bindings(self, variable: str) -> int: + """How many places bind `variable` (`_binding_nodes`).""" + return len(self._binding_nodes(variable)) + + def _binding_nodes(self, variable: str) -> list[ast.AST]: + """The places that bind `variable`: a store or a deletion of the name, a parameter, an import, a function or + class name, an exception or `match` capture, a type parameter. A `case x:` would rebind a name the walk + checked as a literal (`match "open('x')": case COLUMN: ...`), so every kind counts.""" + nodes: list[ast.AST] = [ + name for name in self.of(ast.Name) if name.id == variable and not isinstance(name.ctx, ast.Load) + ] + nodes.extend(parameter for parameter in self.of(ast.arg) if parameter.arg == variable) + imports: list[ast.Import | ast.ImportFrom] = [*self.of(ast.Import), *self.of(ast.ImportFrom)] + nodes.extend( + alias for node in imports for alias in node.names if (alias.asname or alias.name.split(".")[0]) == variable + ) + named: list[ast.AST] = [ + *self.of(ast.FunctionDef), + *self.of(ast.AsyncFunctionDef), + *self.of(ast.ClassDef), + *self.of(ast.ExceptHandler), + *self.of(ast.MatchAs), + *self.of(ast.MatchStar), + *self.of(ast.TypeVar), + *self.of(ast.ParamSpec), + *self.of(ast.TypeVarTuple), + ] + nodes.extend(node for node in named if getattr(node, "name", None) == variable) + nodes.extend(node for node in self.of(ast.MatchMapping) if node.rest == variable) + return nodes + def _check_expression(self, text: str, call_name: str, node: ast.Constant) -> None: """One expression string: every parsable term walked with this profile's name, attribute, call, save and buffer-writer rules. A bare identifier and a text that does not parse are column names plotnine looks up.""" @@ -2101,11 +2512,151 @@ def _check_expression(self, text: str, call_name: str, node: ast.Constant) -> No inner._check_statements() inner._check_strings() inner._check_save(None) + inner._check_save_names() # `aes(x="(lambda g: g(p, 'x.pdf'))(ggsave)")` reaches the plot's namespace for finding in inner.findings: self.add(finding.rule, f"in the {call_name} expression: {finding.message}", node) if term == text: break # the whole string parsed: its terms are already in it + # -- pygal and lets-plot ------------------------------------------------------------- + + def _check_pygal_config(self) -> None: + """pygal's `css`, `js` and `defs` options as keywords, `**` arguments and attributes (module docstring, + "pygal's config").""" + for keyword in self.of(ast.keyword): + if keyword.arg in PYGAL_CONFIG_KEYS: + self._check_pygal_option(keyword.arg, keyword.value, keyword) + elif keyword.arg is None: + self._check_pygal_options(keyword) + for attribute in self.of(ast.Attribute): + if attribute.attr not in PYGAL_CONFIG_KEYS: + continue + parent = self.parents.get(attribute) + if attribute.attr == "defs": + self.add("banned-attribute", "'.defs' is not allowed: it is raw SVG markup", attribute) + elif ( + isinstance(attribute.ctx, ast.Store) + and isinstance(parent, ast.Assign) + and len(parent.targets) == 1 + and parent.targets[0] is attribute + ): + self._check_pygal_option(attribute.attr, parent.value, parent) + else: + # `chart.css += [...]`, `chart.css.append(...)`, `chart.css[0] = ...` or `x = chart.css`: the list + # would change after the check. + message = f"'.{attribute.attr}' may only be assigned a whole list, as in .{attribute.attr} = [...]" + self.add("banned-attribute", message, attribute) + + def _check_pygal_option(self, key: str, value: ast.expr, node: ast.AST) -> None: + """One `css`, `js` or `defs` value: `defs` never, `js` an empty list, `css` a list of `inline:` texts and + pygal's own stylesheets, which the URL rule then lets pass.""" + if key == "defs": + self.add("banned-keyword", "the keyword 'defs=' is not allowed: it is raw SVG markup", node) + return + if isinstance(value, ast.Name): + value = self._module_value(value.id, self._is_option_read) or value + if not isinstance(value, ast.List | ast.Tuple): + self.add("banned-keyword", f"{key}= takes a list literal, or a name bound once to one", node) + return + if key == "js": + if value.elts: + self.add("banned-keyword", "js= takes only [] (an entry is a script file read or fetched)", node) + return + for item in value.elts: + if isinstance(item, ast.Name): + item = self._module_value(item.id) or item + if isinstance(item, ast.Constant) and item.value in PYGAL_PACKAGE_CSS: + self.url_exempt.add(item) + elif not _is_inline_css(item): + message = "css= takes only 'inline:...' texts and pygal's own file://style.css, graph.css and base.css" + self.add("banned-keyword", message, node) + + def _is_option_read(self, name: ast.Name) -> bool: + """`name` is read only where the config rule checks it: as a `css=` or `js=` value, or assigned to `.css` or + `.js`.""" + parent = self.parents.get(name) + if isinstance(parent, ast.keyword): + return parent.arg in ("css", "js") + return ( + isinstance(parent, ast.Assign) + and parent.value is name + and len(parent.targets) == 1 + and isinstance(parent.targets[0], ast.Attribute) + and parent.targets[0].attr in ("css", "js") + ) + + def _check_pygal_options(self, keyword: ast.keyword) -> None: + """A `**` argument: a dict literal with string keys, or a name bound once at module level to one and used only + as `**name`, whose `css`, `js` and `defs` keys are checked like keywords.""" + value: ast.expr | None = keyword.value + if isinstance(value, ast.Name): + value = self._module_value(value.id, self._is_unpacked) + if not ( + isinstance(value, ast.Dict) + and all(isinstance(key, ast.Constant) and isinstance(key.value, str) for key in value.keys) + ): + message = "** takes only a dict literal with string keys, or a name bound once to one and only unpacked" + self.add("banned-keyword", message, keyword) + return + for key, item in zip(value.keys, value.values, strict=True): + if isinstance(key, ast.Constant) and key.value in PYGAL_CONFIG_KEYS: + self._check_pygal_option(key.value, item, key) + + def _is_unpacked(self, name: ast.Name) -> bool: + parent = self.parents.get(name) + return isinstance(parent, ast.keyword) and parent.arg is None + + def _check_letsplot_object(self) -> None: + """`LetsPlot` only as `LetsPlot.setup_html()` without arguments (module docstring, "lets-plot").""" + nodes: list[ast.Name | ast.Attribute] = [*self.of(ast.Name), *self.of(ast.Attribute)] + for node in nodes: + if not isinstance(node.ctx, ast.Load) or self.resolve(node) != _LETSPLOT_OBJECT: + continue + attribute = self.parents.get(node) + call = self.parents.get(attribute) if attribute is not None else None + if not ( + isinstance(attribute, ast.Attribute) + and attribute.value is node + and attribute.attr == "setup_html" + and isinstance(call, ast.Call) + and call.func is attribute + and not call.args + and not call.keywords + ): + self.add("banned-call", "LetsPlot may only be used as LetsPlot.setup_html(), without arguments", node) + + def _is_builder_format(self, attribute: ast.Attribute) -> bool: + """`layer_tooltips().line("@x").format("@x", ".1f")`: `.format` called with string literals on a chain of + builder methods rooted in a call of a lets-plot builder bound only by its `from` import.""" + call = self.parents.get(attribute) + if not (isinstance(call, ast.Call) and call.func is attribute): + return False + if not all(_is_text(argument) for argument in call.args): + return False + if not all(keyword.arg in ("field", "format") and _is_text(keyword.value) for keyword in call.keywords): + return False + node = attribute.value + while isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Attribute) and func.attr in LETSPLOT_BUILDER_METHODS: + node = func.value + continue + return isinstance(func, ast.Name) and self._is_builder_name(func.id) + return False + + def _is_builder_name(self, variable: str) -> bool: + """`variable` is bound once, by `from lets_plot import layer_tooltips [as variable]` or another builder, and + no attribute of that name is ever assigned: `lets_plot.layer_tooltips = str` before the import would hand the + import another callable.""" + if self._bindings(variable) != 1: + return False + for node in self.of(ast.ImportFrom): + for alias in node.names if node.module and not node.level else []: + if f"{node.module}.{alias.name}" in LETSPLOT_BUILDERS and (alias.asname or alias.name) == variable: + stores = self.of(ast.Attribute) + return not any(store.attr == alias.name and not isinstance(store.ctx, ast.Load) for store in stores) + return False + # -- ADAPTATION ---------------------------------------------------------------------- def adaptation(self, original_palette: list[str]) -> None: diff --git a/agents/anyplot/render/harness.py b/agents/anyplot/render/harness.py index 70039e080c6..aba3cfd96c5 100644 --- a/agents/anyplot/render/harness.py +++ b/agents/anyplot/render/harness.py @@ -50,7 +50,9 @@ 5. compiles the plot file and runs it as `__main__` (`run_plot`, which `runpy.run_path` did before) under `PlotGuard`, an audit hook armed just before: from there on the interpreter refuses every `exec` but the plot's own run, the module bodies of an import - and `CODE_BUILDERS` (`namedtuple`, dataclasses, scipy's distributions), a code object or + and `CODE_BUILDERS` (`namedtuple`, dataclasses, scipy's distributions, plotnine's + expression strings, and cssselect2's selector compiler, which pygal's PNG export runs for + every CSS rule of the chart), a code object or function built by hand or a `__code__` assignment, every `open` that writes anything but `plot-.png`, `probe-.json` and `plot-.html` (altair's second file, which stays in the sandbox), `subprocess.*`, `os.system`, `os.exec*`, @@ -355,6 +357,7 @@ ("dataclasses.py", "add_fns_to_class"), ("scipy/stats/_distn_infrastructure.py", "_attach_argparser_methods"), ("plotnine/mapping/_env.py", "eval"), + ("cssselect2/compiler.py", "__init__"), } ) """The `(file suffix, function)` pairs whose `exec` and `compile` both guards let through when they call them: each @@ -366,7 +369,19 @@ is not a column name (`aes(fill="factor(g)")`, 7 of 273 eligible catalogue files, and the adapter's habit). The validator admits those strings only as literals it walked as code with every rule of the profile (`validate.EXPRESSION_CALLS`), and whatever the evaluated code reaches (writes, processes, sockets, frames) stays -refused here; patsy's `exec` behind `formula=` has no entry, the keyword is a validator finding.""" +refused here; patsy's `exec` behind `formula=` has no entry, the keyword is a validator finding. cssselect2's +`CompiledSelector.__init__` turns every CSS selector of an SVG `", "\\n") + + with open(f"plot-{THEME}.svg", "w", encoding="utf-8") as f: + f.write(output_svg) + + # Convert to PNG using cairosvg + cairosvg.svg2png(bytestring=output_svg.encode("utf-8"), write_to=f"plot-{THEME}.png") + + # Save HTML + html_content = f""" + {output_svg}""" + + with open(f"plot-{THEME}.html", "w", encoding="utf-8") as f: + f.write(html_content) + ''') + report = normalise_report(code, library="pygal") + + assert report.code.endswith( + '\n\n# Convert to PNG using cairosvg\ncairosvg.svg2png(bytestring=output_svg.encode("utf-8"), ' + 'write_to=f"plot-{THEME}.png")\n' + ) + assert "open(" not in report.code and "html_content" not in report.code and '.svg"' not in report.code + assert "output_svg = base_svg.replace(" in report.code # it also feeds the PNG + assert report.changes[:3] == ( + "removed the SVG write through open() at lines 15-16", + "removed the HTML write through open() at lines 25-26", + "removed html_content, which only fed a removed write, at lines 22-23", + ) + + +def test_pygal_svg_save_goes() -> None: + """network-basic and nine more files also save the theme SVG, which the plot sandbox refuses.""" + code = pygal_code(""" + chart = pygal.XY(width=3200, height=1800) + chart.render_to_png(f"plot-{THEME}.png") + # The SVG for the web + chart.render_to_file(f"plot-{THEME}.svg") + """) + report = normalise_report(code, library="pygal") + + assert report.code.endswith('chart.render_to_png(f"plot-{THEME}.png")\n') + assert report.changes == ("removed the SVG save at line 12",) + + +def test_pygal_write_whose_target_is_read_again_stays() -> None: + """network-hierarchical renders the SVG file and converts it with `svg2png(url=...)`.""" + code = pygal_code(""" + chart = pygal.XY(width=3200, height=1800) + chart.render_to_file(f"plot-{THEME}.svg") + with open(f"plot-{THEME}.svg", "w") as f: + f.write(chart.render(is_unicode=True)) + cairosvg.svg2png(url=f"plot-{THEME}.svg", write_to=f"plot-{THEME}.png") + """) + report = normalise_report(code, library="pygal") + + assert report.code == code + assert "html: the SVG save at line 10 is read again elsewhere; left in place" in report.notes + assert "html: the SVG write through open() at lines 11-12 is read again elsewhere; left in place" in report.notes + + +def test_pygal_feeder_with_a_save_call_stays() -> None: + code = pygal_code(""" + chart = pygal.XY(width=3200, height=1800) + png = chart.render_to_png() + with open(f"plot-{THEME}.html", "w") as f: + f.write(f"") + chart.render_to_png(f"plot-{THEME}.png") + """) + result = norm(code, "pygal") + + assert "png = chart.render_to_png()" in result and "open(" not in result + + +def test_pygal_html_page_that_assigns_inside_its_block_goes() -> None: + """heatmap-basic renders the chart into a name inside the `with` and writes a page around it.""" + code = pygal_code(""" + chart = pygal.XY(width=3200, height=1800) + chart.render_to_png(f"plot-{THEME}.png") + with open(f"plot-{THEME}.html", "w", encoding="utf-8") as fout: + pygal_svg = chart.render(is_unicode=True) + fout.write(f"{pygal_svg}") + """) + report = normalise_report(code, library="pygal") + + assert report.code.endswith('chart.render_to_png(f"plot-{THEME}.png")\n') + assert report.changes == ("removed the HTML write through open() at lines 11-13",) + assert norm(code, "pygal") == report.code + + +@pytest.mark.parametrize( + "block", + [ + # the name is read after the block + 'with open(f"plot-{THEME}.html", "w") as f:\n page = ""\n f.write(page)\nprint(page)', + # the assignment holds a save call + 'with open(f"plot-{THEME}.html", "w") as f:\n png = chart.render_to_png()\n f.write(str(png))', + # a tuple target, and a statement that is neither a write nor an assignment + 'with open(f"plot-{THEME}.html", "w") as f:\n a, b = "<", ">"\n f.write(a + b)', + 'with open(f"plot-{THEME}.html", "w") as f:\n print("page")\n f.write("")', + # nothing written + 'with open(f"plot-{THEME}.html", "w") as f:\n page = ""', + ], +) +def test_pygal_html_block_that_does_more_stays(block: str) -> None: + code = pygal_code('chart = pygal.XY(width=3200, height=1800)\nchart.render_to_png(f"plot-{THEME}.png")\n') + ( + block + "\n" + ) + assert norm(code, "pygal") == code + + +def test_pygal_html_step_repeats_once_a_removed_feeder_frees_a_target() -> None: + """A hand-built page's target is named again in a feeder of the SVG write; once that goes, the page goes too.""" + code = pygal_code(""" + chart = pygal.XY(width=3200, height=1800) + chart.render_to_png(f"plot-{THEME}.png") + link = f"plot-{THEME}.html" + with open(f"plot-{THEME}.svg", "w") as f: + f.write(link) + with open(f"plot-{THEME}.html", "w") as f: + f.write("") + """) + report = normalise_report(code, library="pygal") + + assert report.code.endswith('chart.render_to_png(f"plot-{THEME}.png")\n') + assert report.changes == ( + "removed the SVG write through open() at lines 12-13", + "removed link, which only fed a removed write, at line 11", + "removed the HTML write through open() at lines 11-12", + ) + assert report.notes == () # the first pass's "named again" note is gone with the name + + +def test_html_writes_stay_for_the_other_libraries() -> None: + code = THEME_BLOCK + 'with open(f"plot-{THEME}.html", "w") as f:\n f.write(chart.render(is_unicode=True))\n' + assert norm(code) == code + + +def test_pygal_html_open_writes_the_same_bytes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The rewrite's claim, for the three write forms: pygal's `render_to_file` writes what `open()` wrote.""" + pytest.importorskip("pygal") + import pygal.graph.base + + monkeypatch.setattr(pygal.graph.base, "uuid4", lambda: "00000000-0000-0000-0000-000000000000") + monkeypatch.setenv("ANYPLOT_THEME", "light") + chart = "chart = pygal.Bar(width=320, height=180, js=[])\nchart.add('a', [1, 3, 2])\n" + for number, write in enumerate( + [ + 'with open(f"plot-{THEME}.html", "wb") as f:\n f.write(chart.render())', + 'with open(f"plot-{THEME}.html", "w", encoding="utf-8") as f:\n f.write(chart.render(is_unicode=True))', + 'with open(f"plot-{THEME}.html", "w", encoding="utf-8") as f:\n f.write(chart.render().decode("utf-8"))', + ] + ): + original = 'import os\n\nimport pygal\n\nTHEME = os.getenv("ANYPLOT_THEME", "light")\n' + chart + write + "\n" + normalised = normalise(original, library="pygal") + assert "render_to_file" in normalised + written = [] + for form, code in (("original", original), ("normalised", normalised)): + directory = tmp_path / f"{number}-{form}" + directory.mkdir() + monkeypatch.chdir(directory) + exec(compile(code, "plot.py", "exec"), {"__name__": "__main__"}) + written.append((directory / "plot-light.html").read_bytes()) + assert written[0] == written[1], write + + +@pytest.mark.parametrize( + ("chart", "size", "target"), + [ + ("pygal.StackedLine(width=4800, height=2700, js=[])", "4800x2700", LANDSCAPE), + ("pygal.Pie(width=3600, height=3600)", "3600x3600", SQUARE), + ], +) +def test_pygal_large_chart_renders_the_canvas_through_svg2png(chart: str, size: str, target: tuple[int, int]) -> None: + """98 catalogue charts are 4800x2700 and 4 are 3600x3600; the layout stays, the PNG gets the canvas size.""" + code = pygal_code(f""" + chart = {chart} + chart.add("a", [1, 2, 3]) + chart.render_to_png(f"plot-{{THEME}}.png") + """) + report = normalise_report(code, library="pygal") + + width, height = target + assert report.code.startswith("import os\n\nimport cairosvg\nimport numpy as np\nimport pygal\n") + assert report.code.endswith( + f'cairosvg.svg2png(bytestring=chart.render(), write_to=f"plot-{{THEME}}.png", output_width={width}, ' + f"output_height={height})\n" + ) + assert f"chart = {chart}" in report.code + assert report.changes == ( + "added import cairosvg at line 3", + f"canvas: pygal chart {size} (the saved chart at line 9) renders {size}; the save renders {width}x{height} " + f"through cairosvg.svg2png(output_width={width}, output_height={height}) in place of render_to_png", + ) + canvas = measure_canvas(report.code, library="pygal") + assert canvas.on_target and canvas.pixels == target + assert not measure_canvas(code, library="pygal").on_target + assert norm(code, "pygal") == report.code + + +def test_pygal_svg2png_save_gets_the_output_size() -> None: + """Traced through `render`, `replace`, `encode` and names; an existing `import cairosvg as _cairosvg` is used.""" + code = pygal_code(""" + import cairosvg as _cairosvg + + chart = pygal.XY(width=3600, height=3600) + svg = chart.render(is_unicode=True) + marked = svg.replace("", "") + _cairosvg.svg2png(bytestring=marked.encode("utf-8"), write_to=f"plot-{THEME}.png", output_width=4800) + """) + report = normalise_report(code, library="pygal") + + assert report.code.endswith( + '_cairosvg.svg2png(bytestring=marked.encode("utf-8"), write_to=f"plot-{THEME}.png", output_width=2400, ' + "output_height=2400)\n" + ) + assert report.changes[-1].startswith("canvas: pygal chart 3600x3600 (the saved chart at line 11) renders 4800x4800") + assert measure_canvas(report.code, library="pygal").pixels == SQUARE + + +def test_pygal_from_import_of_svg2png_is_used() -> None: + code = pygal_code(""" + from cairosvg import svg2png + + chart = pygal.XY(width=4800, height=2700) + chart.render_to_png(filename=f"plot-{THEME}.png", dpi=96) + """) + result = norm(code, "pygal") + + assert result.endswith( + 'svg2png(bytestring=chart.render(), write_to=f"plot-{THEME}.png", dpi=96, output_width=3200, ' + "output_height=1800)\n" + ) + assert "import cairosvg" not in result + + +@pytest.mark.parametrize( + ("body", "note", "pixels"), + [ + ( + 'chart = pygal.XY(width=size * 2, height=size)\nchart.render_to_png(f"plot-{THEME}.png")', + "canvas: the chart's width= at line 10 is not a positive number or a module-level name bound once to " + "one; the PNG size is left as it is", + None, + ), + ( + 'chart = pygal.XY(width=4800, height=3600)\nchart.render_to_png(f"plot-{THEME}.png")', + "canvas: pygal chart 4800x3600 (the saved chart at line 10) has another aspect than 16:9 or 1:1; the PNG " + "size is left as it is", + (4800, 3600), + ), + ( + 'chart = pygal.XY()\nchart.render_to_png(f"plot-{THEME}.png")', + "canvas: pygal chart 800x600 (the saved chart at line 10) has another aspect than 16:9 or 1:1; the PNG " + "size is left as it is", + (800, 600), + ), + ( + "a = pygal.XY(width=4800, height=2700)\nb = pygal.Bar(width=4800, height=2700)\n" + 'cairosvg.svg2png(bytestring=merge(a, b), write_to=f"plot-{THEME}.png")', + "canvas: no pygal chart traced from the save at line 12 (the file has 2); the PNG size is left as it is", + None, + ), + ( + 'cairosvg.svg2png(bytestring=build(size).encode(), write_to=f"plot-{THEME}.png")', + "canvas: no pygal chart traced from the save at line 10; the PNG size is left as it is", + None, + ), + # sizes pygal reads at render, after the constructor: never its 800x600 then + ( + 'chart = pygal.Bar()\nchart.width = 4800\nchart.height = 2700\nchart.render_to_png(f"plot-{THEME}.png")', + "canvas: chart.width at line 11 sets the chart's size after its constructor; the PNG size is left as it is", + None, + ), + ( + 'chart = pygal.Bar()\nc = chart\nc.config.height = 2700\nchart.render_to_png(f"plot-{THEME}.png")', + "canvas: c.config.height at line 12 sets the chart's size after its constructor; the PNG size is left as " + "it is", + None, + ), + ( + 'chart = pygal.Bar()\nchart.config(width=4800, height=2700)\nchart.render_to_png(f"plot-{THEME}.png")', + "canvas: chart.config(...) at line 11 may set the chart's size after its constructor; the PNG size is " + "left as it is", + None, + ), + ( + 'cfg = pygal.Config()\nchart = pygal.Bar(config=cfg)\nchart.render_to_png(f"plot-{THEME}.png")', + "canvas: the chart at line 11 takes a pygal.Config, which may set its size; the PNG size is left as it is", + None, + ), + ( + 'chart = pygal.Bar(pygal.Config)\nchart.render_to_png(f"plot-{THEME}.png")', + "canvas: the chart at line 10 takes positional arguments (a pygal.Config) or **kwargs; the PNG size is " + "left as it is", + None, + ), + ( + "chart = pygal.Bar()\n" + 'cairosvg.svg2png(bytestring=chart.render(width=size, height=2700), write_to=f"plot-{THEME}.png")', + "canvas: the render at line 11 takes keywords that may set the chart's size; the PNG size is left as it is", + None, + ), + # a save that writes no PNG: the readiness scan blocks it, the canvas step leaves it alone + ( + 'chart = pygal.Bar(width=4800, height=2700)\nchart.render_to_file(f"plot-{THEME}.png")', + "canvas: the render_to_file at line 11 writes no PNG; nothing to normalise", + None, + ), + ], +) +def test_pygal_canvas_left_alone_with_a_note(body: str, note: str, pixels: tuple[int, int] | None) -> None: + code = PYGAL_HEAD + "size = 4800\n" + body + "\n" + report = normalise_report(code, library="pygal") + + assert report.code == code + assert note in report.notes, report.notes + canvas = measure_canvas(code, library="pygal") + assert not canvas.on_target and canvas.pixels == pixels + + +def test_pygal_canvas_already_on_target_is_kept() -> None: + code = pygal_code('chart = pygal.XY(width=3200, height=1800)\nchart.render_to_png(f"plot-{THEME}.png")\n') + assert norm(code, "pygal") == code + assert measure_canvas(code, library="pygal").pixels == LANDSCAPE + + +@pytest.mark.parametrize( + ("build", "size", "line", "target"), + [ + # dendrogram-radial: implicitly joined f-strings and the module constants they interpolate + ( + """ + W, H = 4800, 2700 + svg = ( + '\\n' + f'\\n' + f'\\n' + "\\n".join(elems) + "" + ) + """, + "4800x2700", + 14, + LANDSCAPE, + ), + # histogram-2d: the root tag in a list the file appends to and joins + ( + """ + svg_parts = [] + svg_parts.append('') + for elem in elems: + svg_parts.append(f'') + svg_parts.append("") + svg = "\\n".join(svg_parts) + """, + "4800x2700", + 13, + LANDSCAPE, + ), + # ternary-density: one triple-quoted f-string, px units and another name for the size + ( + ''' + chart_width = 3600 + chart_height = 3600 + title_svg = f't' + svg = f""" + {title_svg} + """ + ''', + "3600x3600", + 15, + SQUARE, + ), + ], +) +def test_pygal_hand_built_svg_renders_the_canvas(build: str, size: str, line: int, target: tuple[int, int]) -> None: + """Four catalogue files draw their own SVG at 4800x2700 or 3600x3600 and convert it with svg2png.""" + code = pygal_code( + "import cairosvg\n\nelems = []\n" + + source(build) + + 'cairosvg.svg2png(bytestring=svg.encode("utf-8"), write_to=f"plot-{THEME}.png")\n' + ) + report = normalise_report(code, library="pygal") + + width, height = target + assert report.code.endswith( + f'cairosvg.svg2png(bytestring=svg.encode("utf-8"), write_to=f"plot-{{THEME}}.png", output_width={width}, ' + f"output_height={height})\n" + ) + assert report.changes == ( + f"canvas: hand-built SVG {size} (the root at line {line}) renders {size}; the save renders " + f"{width}x{height} through cairosvg.svg2png(output_width={width}, output_height={height})", + ) + before = measure_canvas(code, library="pygal") + assert before.pixels == tuple(int(side) for side in size.split("x")) + assert (before.note or "").startswith(f"pygal renders {size} (the root at line {line}), target ") + assert measure_canvas(report.code, library="pygal").pixels == target + assert norm(code, "pygal") == report.code + + +@pytest.mark.parametrize( + ("build", "note"), + [ + ( + 'svg = f\'\'', + "the root at line 12 has no width= of a number or a module-level name bound once to one", + ), + ( + 'W = 3200\nsvg = f\'\'', # W is bound twice + "the root at line 13 has no width= of a number or a module-level name bound once to one", + ), + ( + 'svg = \'\'', + "the root at line 12 has no height= of a number or a module-level name bound once to one", + ), + ( + 'inner = \'\'\nsvg = \'\' + inner', + "no pygal chart traced from the save at line 14, and 2 tags at line 12, 13", + ), + ], +) +def test_pygal_hand_built_svg_of_unknown_size_is_left_alone(build: str, note: str) -> None: + code = pygal_code("import cairosvg\n\nW = 4800\n" + build + "\n") + ( + 'cairosvg.svg2png(bytestring=svg.encode(), write_to=f"plot-{THEME}.png")\n' + ) + report = normalise_report(code, library="pygal") + + assert report.code == code + assert f"canvas: {note}; the PNG size is left as it is" in report.notes, report.notes + canvas = measure_canvas(code, library="pygal") + assert canvas.pixels is None and canvas.note == f"pygal canvas unknown: {note}" + + +def test_pygal_output_size_names_are_read() -> None: + """chord-basic and ecg-twelve-lead size the PNG through module constants; `W = H = 2400` binds both once.""" + code = pygal_code(""" + import cairosvg + + W = H = 2400 + chart = pygal.XY(width=W, height=H) + cairosvg.svg2png(bytestring=chart.render(), write_to=f"plot-{THEME}.png", output_width=W, output_height=H) + """) + assert measure_canvas(code, library="pygal").pixels == SQUARE + assert norm(code, "pygal") == code + + +def test_pygal_import_below_the_save_is_not_used() -> None: + """An `import cairosvg` after the save would come too late for the rewritten save; one goes into the imports.""" + code = pygal_code(""" + chart = pygal.Bar(width=4800, height=2700) + chart.render_to_png(f"plot-{THEME}.png") + import cairosvg + """) + result = norm(code, "pygal") + lines = result.splitlines() + + assert lines.index("import cairosvg") < next(i for i, line in enumerate(lines) if "cairosvg.svg2png(" in line) + assert result.endswith( + 'cairosvg.svg2png(bytestring=chart.render(), write_to=f"plot-{THEME}.png", output_width=3200, ' + "output_height=1800)\nimport cairosvg\n" + ) + + +# --- lets-plot ----------------------------------------------------------------------------- + + +LETSPLOT_HEAD = source(""" + import os + + import pandas as pd + from lets_plot import LetsPlot, aes, geom_point, ggplot, ggsave, ggsize + + LetsPlot.setup_html() + THEME = os.getenv("ANYPLOT_THEME", "light") + df = pd.DataFrame({"x": [1, 2], "y": [3, 4]}) +""") + + +def test_letsplot_catalogue_shape_in_miniature() -> None: + """A star import, the `export_ggsave` alias, saves without `path=` and the lets-plot-images epilogue.""" + code = source(""" + import os + import shutil + + import pandas as pd + from lets_plot import * # noqa: F403 + from lets_plot.export import ggsave as export_ggsave + + LetsPlot.setup_html() + THEME = os.getenv("ANYPLOT_THEME", "light") + df = pd.DataFrame({"x": [1, 2], "y": [3, 4]}) + plot = ggplot(df, aes(x="x", y="y")) + geom_point() + ggsize(1600, 900) + + export_ggsave(plot, f"plot-{THEME}.png", scale=3) + export_ggsave(plot, filename=f"plot-{THEME}.html") + + # Move files from lets-plot-images to current directory + if os.path.exists("lets-plot-images"): + for f in [f"plot-{THEME}.png", f"plot-{THEME}.html"]: + src = os.path.join("lets-plot-images", f) + if os.path.exists(src): + shutil.move(src, f) + if not os.listdir("lets-plot-images"): + shutil.rmtree("lets-plot-images") + """) + expected = source(""" + import os + + import pandas as pd + from lets_plot import ( + LetsPlot, + aes, + geom_point, + ggplot, + ggsize, + ) + from lets_plot.export import ggsave + + LetsPlot.setup_html() + THEME = os.getenv("ANYPLOT_THEME", "light") + df = pd.DataFrame({"x": [1, 2], "y": [3, 4]}) + plot = ggplot(df, aes(x="x", y="y")) + geom_point() + ggsize(1600, 900) + + ggsave(plot, f"plot-{THEME}.png", path=".", scale=2) + ggsave(plot, filename=f"plot-{THEME}.html", path=".") + """) + report = normalise_report(code, library="letsplot") + + assert report.code == expected + assert report.notes == () + assert report.changes == ( + "import ggsave as export_ggsave -> import ggsave at line 6", + "export_ggsave -> ggsave at line 13, 14", + "from lets_plot import * -> 5 names at line 5", + 'added path="." to ggsave at line 19', + 'added path="." to ggsave at line 20', + "removed the unused `import shutil` at line 2", + "removed the lets-plot-images epilogue at lines 23-29", + "canvas: 1600x900 (ggsize at line 16) at scale 3 renders 4800x2700; scale=2 renders 3200x1800", + ) + assert norm(code, "letsplot") == expected + assert measure_canvas(expected, library="letsplot").pixels == LANDSCAPE + + +@pytest.mark.parametrize( + "clash", ["ggsave = None", "def save(export_ggsave):\n return export_ggsave", "from plotnine import ggsave"] +) +def test_letsplot_save_alias_stays_when_a_name_is_bound_again(clash: str) -> None: + code = LETSPLOT_HEAD.replace(", ggsave,", ",") + "from lets_plot.export import ggsave as export_ggsave\n\n" + code += clash + '\nexport_ggsave(ggplot(), f"plot-{THEME}.png", path=".", scale=2)\n' + report = normalise_report(code, library="letsplot") + + assert "import ggsave as export_ggsave" in report.code + assert any( + note.startswith("save alias: ") and note.endswith("export_ggsave left unchanged") for note in report.notes + ) + + +def test_letsplot_star_import_lists_the_names_the_module_reads() -> None: + """renko-basic, with an explicit import kept, a name the file assigns (column-stratigraphic's `margin`) and a + name read only inside a function.""" + code = source(""" + import os + + from lets_plot import * + from lets_plot import element_blank, theme + + THEME = os.getenv("ANYPLOT_THEME", "light") + margin = 16 + LetsPlot.setup_html() + + + def styled(plot): + return plot + theme_minimal() + theme(panel_grid=element_blank()) + + + plot = styled(ggplot() + geom_tile(aes(x="x"), size=margin) + scale_fill_manual(values=["#009E73"])) + plot += ggsize(1600, 900) + labs(title="") + ggsave(plot, f"plot-{THEME}.png", path=".", scale=2) + """) + result = norm(code, "letsplot") + + assert result.startswith( + "import os\n\nfrom lets_plot import (\n LetsPlot,\n aes,\n geom_tile,\n ggplot,\n ggsave,\n" + " ggsize,\n labs,\n scale_fill_manual,\n theme_minimal,\n)\n" + "from lets_plot import element_blank, theme\n" + ) + + +@pytest.mark.parametrize( + ("code", "note"), + [ + ( + "from lets_plot import *\nfrom math import *\nplot = ggplot() + geom_point(size=pi)\n", + "star import: the file has 2 star imports; the star import at line 1 stays", + ), + ( + "from lets_plot import *\n\n\ndef keep():\n global plot\n plot = ggplot()\n", + "star import: a global statement at line 5; the star import at line 1 stays", + ), + ( + "from lets_plot import *\nplot = ggplot() + theme(legend_position='top')\ntheme = None\n", + "star import: theme may be read before the file binds it; the star import at line 1 stays", + ), + # the statement reads the name before it binds it + ( + "from lets_plot import *\nlabs = labs(title='t')\nplot = ggplot() + labs\n", + "star import: labs may be read before the file binds it; the star import at line 1 stays", + ), + # bound only when the condition holds: without the star import the read would raise NameError + ( + "import os\nfrom lets_plot import *\nif os.getenv('X'):\n labs = None\nplot = labs(title='t')\n", + "star import: labs may be read before the file binds it; the star import at line 2 stays", + ), + ( + "try:\n from lets_plot import *\nexcept ImportError:\n pass\n", + "star import: it is not a module-level statement; the star import at line 2 stays", + ), + ( + "from lets_plot import *; plot = ggplot()\n", + "star import: it shares its line with other code; the star import at line 1 stays", + ), + ], +) +def test_letsplot_star_import_refusals_keep_it_with_a_note(code: str, note: str) -> None: + report = normalise_report(code, library="letsplot") + + assert "from lets_plot import *" in report.code + assert note in report.notes, report.notes + + +def test_letsplot_star_import_names_bound_where_they_are_read() -> None: + """column-stratigraphic binds `margin` (a lets-plot name) in a loop and reads it there; a `with` body always + runs.""" + code = source(""" + import os + + from lets_plot import * + + THEME = os.getenv("ANYPLOT_THEME", "light") + for row in range(2): + margin = 0.03 + print(row + margin) + with open(os.devnull) as handle: + labs = handle.name + plot = ggplot() + geom_point(size=len(labs)) + scale_x_log10() + scale_x_log2() + plot += GGBunch() + LetsPlot.setup_html() + """) + result = norm(code, "letsplot") + + assert result.startswith( + "import os\n\nfrom lets_plot import (\n GGBunch,\n LetsPlot,\n geom_point,\n ggplot,\n" + " scale_x_log2,\n scale_x_log10,\n)\n" + ) # ruff's order-by-type: classes first, then natural order + + +def test_letsplot_unused_star_import_goes() -> None: + code = 'import os\nfrom lets_plot import *\n\nTHEME = os.getenv("ANYPLOT_THEME", "light")\n' + report = normalise_report(code, library="letsplot") + + assert report.code == 'import os\n\nTHEME = os.getenv("ANYPLOT_THEME", "light")\n' + assert report.changes[0] == "removed the unused `from lets_plot import *` at line 2" + + +@pytest.mark.parametrize( + ("saves", "expected"), + [ + # hive-basic: no path=, so lets-plot writes into ./lets-plot-images (the sandbox refuses the os.mkdir) + ( + 'ggsave(plot, f"plot-{THEME}.png", scale=3)\nggsave(plot, filename=f"plot-{THEME}.html")', + 'ggsave(plot, f"plot-{THEME}.png", path=".", scale=2)\nggsave(plot, filename=f"plot-{THEME}.html", path=".")', + ), + # scatter-annotated: a computed directory, and lets-plot's default spelled out + ( + "_script_dir = Path(__file__).parent\n" + 'ggsave(plot, f"plot-{THEME}.png", path=str(_script_dir), scale=3)\n' + 'ggsave(plot, f"plot-{THEME}.html", path=None)', + 'ggsave(plot, f"plot-{THEME}.png", path=".", scale=2)\nggsave(plot, f"plot-{THEME}.html", path=".")', + ), + # gain-curve: the shared target rewrite first, then the directory + ( + "output_dir = os.getcwd()\n" + 'ggsave(plot, os.path.join(output_dir, f"plot-{THEME}.png"), scale=3)\n' + 'ggsave(plot, os.path.join(output_dir, f"plot-{THEME}.html"))', + 'ggsave(plot, f"plot-{THEME}.png", path=".", scale=2)\nggsave(plot, f"plot-{THEME}.html", path=".")', + ), + ], +) +def test_letsplot_saves_get_the_directory(saves: str, expected: str) -> None: + code = "from pathlib import Path\n\n" + LETSPLOT_HEAD + "plot = ggplot(df) + geom_point() + ggsize(1600, 900)\n" + result = norm(code + saves + "\n", "letsplot") + + assert result.endswith(expected + "\n") + assert "Path" not in result.replace("LetsPlot", "") and "output_dir" not in result + + +def test_letsplot_other_literal_directory_stays_with_a_note() -> None: + code = ( + LETSPLOT_HEAD + 'plot = ggplot() + ggsize(1600, 900)\nggsave(plot, f"plot-{THEME}.png", path="out", scale=2)\n' + ) + report = normalise_report(code, library="letsplot") + + assert 'path="out"' in report.code + assert "save directory: the ggsave at line 10 saves into path='out'; left unchanged" in report.notes + + +@pytest.mark.parametrize( + "epilogue", + [ + # chessboard-pieces: one guarded move per file, then the directory + """ + # Move files from lets-plot-images to current directory + if os.path.exists(f"lets-plot-images/plot-{THEME}.png"): + shutil.move(f"lets-plot-images/plot-{THEME}.png", f"plot-{THEME}.png") + if os.path.exists("lets-plot-images"): + shutil.rmtree("lets-plot-images") + """, + # histogram-overlapping: a listing loop + """ + if os.path.exists("lets-plot-images"): + for file in os.listdir("lets-plot-images"): + src = os.path.join("lets-plot-images", file) + if os.path.isfile(src): + shutil.move(src, file) + shutil.rmtree("lets-plot-images") + """, + # line-3d-trajectory: pathlib names and a copy + """ + lpi_dir = pathlib.Path("lets-plot-images") + if lpi_dir.exists(): + for ext in ["png", "html"]: + src = lpi_dir / f"plot-{THEME}.{ext}" + dst = pathlib.Path(f"plot-{THEME}.{ext}") + if src.exists() and not dst.exists(): + shutil.copy(src, dst) + """, + # line-loss-training: a glob over the directory + """ + images_dir = Path("lets-plot-images") + if images_dir.exists(): + for file in images_dir.glob(f"plot-{THEME}.*"): + shutil.move(str(file), str(file.name)) + """, + # spectrum-basic: a cleanup that swallows OSError + """ + try: + if not os.listdir("lets-plot-images"): + os.rmdir("lets-plot-images") + except OSError: + pass + """, + # swimmer-clinical-timeline: a dead cleanup with its own import + """ + if os.path.exists("lets-plot-images"): + import shutil + + shutil.rmtree("lets-plot-images") + """, + ], +) +def test_letsplot_images_epilogue_goes(epilogue: str) -> None: + saves = source(""" + plot = ggplot(df) + geom_point() + ggsize(1600, 900) + ggsave(plot, f"plot-{THEME}.png", path=".", scale=2) + ggsave(plot, f"plot-{THEME}.html", path=".") + """) + code = "import pathlib\nimport shutil\nfrom pathlib import Path\n" + LETSPLOT_HEAD + saves + "\n" + source(epilogue) + report = normalise_report(code, library="letsplot") + + assert report.code == LETSPLOT_HEAD + saves + assert any(change.startswith("removed the lets-plot-images epilogue") for change in report.changes) + assert report.notes == () + + +def test_letsplot_returned_path_becomes_the_save() -> None: + """line-confidence moves the path ggsave returns; the save then sizes in pixels, so only the scale changes.""" + code = ( + "import shutil\n" + + LETSPLOT_HEAD + + source(""" + plot = ggplot(df) + geom_point() + ggsize(1600, 900) + + # Save as PNG (4800 x 2700 px) + png_path = ggsave(plot, f"plot-{THEME}.png", w=4800, h=2700, unit="px", dpi=100) + shutil.move(png_path, f"plot-{THEME}.png") + + # Save as HTML + html_path = ggsave(plot, f"plot-{THEME}.html") + shutil.move(html_path, f"plot-{THEME}.html") + """) + ) + report = normalise_report(code, library="letsplot") + + assert report.code.endswith( + "# Save as PNG (4800 x 2700 px)\n" + 'ggsave(plot, f"plot-{THEME}.png", path=".", w=4800, h=2700, unit="px", dpi=100, scale=0.6667)\n\n' + '# Save as HTML\nggsave(plot, f"plot-{THEME}.html", path=".")\n' + ) + assert "png_path = ggsave(...) -> ggsave(...) at line 13" in report.changes + assert measure_canvas(report.code, library="letsplot").pixels == LANDSCAPE + + +def test_letsplot_every_returned_path_of_a_name_becomes_the_save() -> None: + """A name bound to two ggsave returns: one pass unwraps both, so a second pass changes nothing.""" + saves = 'png_path = ggsave(plot, f"plot-{THEME}.png", path=".", scale=2)\nshutil.move(png_path, "out.png")\n' + code = "import shutil\n" + LETSPLOT_HEAD + "plot = ggplot(df) + geom_point() + ggsize(1600, 900)\n" + saves * 2 + result = norm(code, "letsplot") + + assert "png_path" not in result and "shutil" not in result + assert result.count('ggsave(plot, f"plot-{THEME}.png", path=".", scale=2)\n') == 2 + + +def test_letsplot_statement_that_does_more_than_move_stays_with_a_note() -> None: + code = LETSPLOT_HEAD + source(""" + lp_dir = "lets-plot-images" + plot = ggplot(df) + ggsize(1600, 900) + ggsave(plot, f"plot-{THEME}.png", path=".", scale=2) + print(os.listdir(lp_dir)) + if os.path.exists(lp_dir): + os.rmdir(lp_dir) + """) + report = normalise_report(code, library="letsplot") + + assert 'lp_dir = "lets-plot-images"' in report.code and "print(os.listdir(lp_dir))" in report.code + assert "os.rmdir" not in report.code + assert "save epilogue: the statement at line 12 uses lets-plot-images in other ways; left in place" in report.notes + + +@pytest.mark.parametrize( + ("plot", "save", "scale", "pixels"), + [ + ("ggsize(1600, 900)", "scale=3", "scale=2", LANDSCAPE), # 106 catalogue files rendered 4800x2700 + ("ggsize(1200, 1200)", "scale=3", "scale=2", SQUARE), + ("ggsize(900, 900)", "scale=3", "scale=2.667", SQUARE), + ("ggsize(800, 450)", "", ", scale=4", LANDSCAPE), # lets-plot's default scale is 2 + ("ggsize(800, 450)", "dpi=150", "dpi=150, scale=2.56", LANDSCAPE), # dpi= scales by dpi / 96 + # w= and h= in px; in a physical unit lets-plot can render one pixel short of the model + ("ggsize(1600, 900)", "w=1600, h=900, unit='px'", "unit='px', scale=2", LANDSCAPE), + # tier 2: inside the canvas, under R2's cap; the exact fit is as short as the decimal below it + ("ggsize(1600, 960)", "scale=3", "scale=1.875", (3000, 1800)), + ("ggsize(1600, 970)", "scale=3", "scale=1.855", (2968, 1799)), # the fit 1.85567... is longer + ], +) +def test_letsplot_canvas_changes_only_the_scale(plot: str, save: str, scale: str, pixels: tuple[int, int]) -> None: + keywords = f", {save}" if save else "" + code = ( + LETSPLOT_HEAD + + f'plot = ggplot(df) + geom_point() + {plot}\nggsave(plot, f"plot-{{THEME}}.png", path="."{keywords})\n' + ) + report = normalise_report(code, library="letsplot") + + assert plot in report.code and scale in report.code.splitlines()[-1] + assert measure_canvas(report.code, library="letsplot").pixels == pixels + assert norm(code, "letsplot") == report.code + + +@pytest.mark.parametrize( + ("plot", "note"), + [ + # candlestick-volume: a grid sizes itself from its panels + ( + "top = ggplot() + ggsize(1600, 630)\nbottom = ggplot() + ggsize(1600, 270)\nplot = gggrid([top, bottom])", + "canvas: gggrid at line 11 has no ggsize of its own; the render relies on the R3 canvas gate", + ), + # an aspect no scale reaches, under R2's cap + ( + "plot = ggplot() + ggsize(800, 853)", + "canvas: 800x853 (ggsize at line 9) at scale 3 renders 2400x2559; no scale renders 3200x1800 or " + "2400x2400, so the render relies on the R3 gate", + ), + ], +) +def test_letsplot_canvas_left_alone_with_a_note(plot: str, note: str) -> None: + code = LETSPLOT_HEAD.replace("ggsave, ggsize", "ggsave, gggrid, ggsize") + plot + "\n" + code += 'ggsave(plot, f"plot-{THEME}.png", path=".", scale=3)\n' + report = normalise_report(code, library="letsplot") + + assert report.code == code + assert note in report.notes, report.notes + assert not measure_canvas(code, library="letsplot").on_target + + +def test_letsplot_plot_size_falls_back_to_the_file_and_the_default() -> None: + added = ( + LETSPLOT_HEAD + + 'plot = ggplot(df) + geom_point()\nplot += ggsize(1600, 900)\nggsave(plot, f"plot-{THEME}.png", path=".", scale=3)\n' + ) + assert measure_canvas(added, library="letsplot").note == ( + "lets-plot renders 4800x2700 from 1600x900 (the file's only ggsize) at scale 3, target 3200x1800" + ) + default = LETSPLOT_HEAD + 'plot = ggplot(df) + geom_point()\nggsave(plot, f"plot-{THEME}.png", path=".")\n' + assert measure_canvas(default, library="letsplot").pixels == (1200, 800) # 600x400 at the default scale 2 + + +def test_max_image_pixels_is_r2s_cap() -> None: + from agents.anyplot.render.png import MAX_IMAGE_PIXELS as R2_CAP + + assert MAX_IMAGE_PIXELS == R2_CAP + + # --- canvas ------------------------------------------------------------------------------ @@ -511,9 +1719,15 @@ def test_measure_canvas_and_effective_dpi() -> None: @pytest.mark.parametrize("path", CATALOGUE_FILES, ids=lambda p: f"{p.parts[-4]}-{p.stem}") def test_catalogue_sweep(path: Path) -> None: original = path.read_text(encoding="utf-8") - result = normalise(original, library=path.stem) + report = normalise_report(original, library=path.stem) + result = report.code assert normalise(result, library=path.stem) == result + if any(change.startswith("canvas:") for change in report.changes) and path.stem in ("pygal", "letsplot"): + rendered = measure_canvas(result, library=path.stem) + assert rendered.pixels is not None and rendered.target is not None, rendered.note + assert all(side <= limit + TOLERANCE for side, limit in zip(rendered.pixels, rendered.target, strict=True)) + assert rendered.pixels[0] * rendered.pixels[1] <= MAX_IMAGE_PIXELS regions = find_regions(original, library=path.stem) size, _ = figure_size(regions) call = regions.savefig or (regions.savefig_calls[-1] if regions.savefig_calls else None) diff --git a/tests/unit/agents/code/test_readiness.py b/tests/unit/agents/code/test_readiness.py index 1c0d4d42990..5a6803860d4 100644 --- a/tests/unit/agents/code/test_readiness.py +++ b/tests/unit/agents/code/test_readiness.py @@ -165,6 +165,176 @@ def test_an_unsupported_library_is_blocked_before_its_save_rule(self) -> None: ] +SIX_ENABLED = (*ALL_ENABLED, "pygal", "letsplot") +PYGAL = source(""" + import os + + import pygal + from pygal.style import Style + + THEME = os.getenv("ANYPLOT_THEME", "light") + PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" + df = load_user_data() + chart = pygal.XY(width=4800, height=2700, style=Style(background=PAGE_BG), js=[]) + chart.add("a", list(zip(df["x"], df["y"]))) + chart.render_to_png(f"plot-{THEME}.png") + with open(f"plot-{THEME}.html", "wb") as f: + f.write(chart.render()) +""") +LETSPLOT = source(""" + import os + + from lets_plot import * + + LetsPlot.setup_html() + THEME = os.getenv("ANYPLOT_THEME", "light") + PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" + df = load_user_data() + plot = ggplot(df, aes(x="x", y="y")) + geom_point(color=PAGE_BG) + ggsize(1600, 900) + ggsave(plot, f"plot-{THEME}.png", scale=3) + ggsave(plot, f"plot-{THEME}.html") +""") + + +def scan_normalised(code: str, library: str) -> Readiness: + return scan( + normalise(code, library=library), spec_id="scatter-basic", library=library, enabled_libraries=SIX_ENABLED + ) + + +class TestPygalAndLetsPlot: + """The catalogue shapes of pygal and lets-plot, normalised, and what the scan says where the normaliser stops.""" + + @pytest.mark.parametrize(("code", "library"), [(PYGAL, "pygal"), (LETSPLOT, "letsplot")]) + def test_normalised_file_is_clean_and_on_the_canvas(self, code: str, library: str) -> None: + assert scan_normalised(code, library) == Readiness("clean", [], []) + + @pytest.mark.parametrize( + ("code", "library", "reason"), + [ + # an unknown size can land over R2's pixel cap: no padding is promised + ( + PYGAL.replace("width=4800, height=2700", "width=W, height=H"), + "pygal", + "canvas: pygal canvas unknown: the chart's width= at line 9 is not a positive number or a module-level " + "name bound once to one; the PNG size shows only in the render, and R2 refuses one over its pixel cap", + ), + ( + LETSPLOT.replace("ggsize(1600, 900)", "ggsize(800, 853)"), + "letsplot", + "canvas: lets-plot renders 2400x2559 from 800x853 (ggsize at line 16) at scale 3, target 2400x2400; the " + "render relies on the R3 canvas gate and padding", + ), + # a known size over the cap: R2 refuses it before R3 sees it + ( + PYGAL.replace("width=4800, height=2700", "width=4800, height=3000"), + "pygal", + "canvas: pygal renders 4800x3000 (the saved chart at line 9), target 3200x1800; R2 refuses a PNG over " + "its pixel cap", + ), + ], + ) + def test_a_canvas_the_normaliser_cannot_set_is_a_note(self, code: str, library: str, reason: str) -> None: + result = scan_normalised(code, library) + + assert result.status == "clean" and result.reasons == [reason] + + def test_a_hand_built_svg_gets_the_canvas(self) -> None: + """dendrogram-radial and three more pygal files draw their SVG at 4800x2700 or 3600x3600 by hand.""" + code = source(""" + import os + + import cairosvg + + THEME = os.getenv("ANYPLOT_THEME", "light") + PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" + df = load_user_data() + W, H = 4800, 2700 + svg = f'' + cairosvg.svg2png(bytestring=svg.encode(), write_to=f"plot-{THEME}.png") + """) + before = scan(code, spec_id="scatter-basic", library="pygal", enabled_libraries=SIX_ENABLED) + + assert before.reasons == [ + "canvas: pygal renders 4800x2700 (the root at line 9), target 3200x1800; R2 refuses a PNG over its " + "pixel cap" + ] + assert scan_normalised(code, "pygal") == Readiness("clean", [], []) + + @pytest.mark.parametrize( + ("save", "reason"), + [ + ( + 'chart.render_to_file(f"plot-{THEME}.png")', # SVG text in the PNG + "savefig-target: render_to_file writes only .html files, not f'plot-{THEME}.png' at line 11", + ), + ( + 'chart.render_to_png(f"plot-{THEME}.html")', # a PNG in the HTML + "savefig-target: render_to_png writes only .png files, not f'plot-{THEME}.html' at line 11", + ), + ], + ) + def test_a_save_name_that_writes_another_kind_of_file_is_blocked(self, save: str, reason: str) -> None: + code = PYGAL.replace('chart.render_to_png(f"plot-{THEME}.png")', save) + result = scan_normalised(code, "pygal") + + assert result.status == "blocked" and reason in result.reasons, result.reasons + + def test_a_letsplot_save_without_the_directory_is_blocked(self) -> None: + """Unnormalised: lets-plot would write into ./lets-plot-images, which the plot sandbox refuses.""" + result = scan(LETSPLOT, spec_id="scatter-basic", library="letsplot", enabled_libraries=SIX_ENABLED) + + assert result.status == "blocked" + assert 'savefig-target: ggsave at line 10 does not save with path="."' in result.reasons + assert 'savefig-target: ggsave at line 11 does not save with path="."' in result.reasons + + @pytest.mark.parametrize( + ("code", "library", "reason"), + [ + # a second star import: the normaliser keeps the lets-plot one, the validator refuses both + ( + LETSPLOT.replace("from lets_plot import *", "from lets_plot import *\nfrom math import *"), + "letsplot", + "security: star-import at line 3: ", + ), + # the alias rename stops where the file binds `ggsave` itself + ( + LETSPLOT.replace( + "from lets_plot import *", + "from lets_plot import *\nfrom lets_plot.export import ggsave as keep\nggsave = keep", + ), + "letsplot", + "security: banned-call at line ", + ), + # an epilogue statement that does more than move files + (LETSPLOT + 'print(os.listdir("lets-plot-images"))\n', "letsplot", "security: os-use at line 19: "), + # an SVG save whose file the code reads again + ( + PYGAL.replace( + 'with open(f"plot-{THEME}.html", "wb") as f:\n f.write(chart.render())', + 'chart.render_to_file(f"plot-{THEME}.svg")\nprint(f"plot-{THEME}.svg")', + ), + "pygal", + "savefig-target: render_to_file saves to f'plot-{THEME}.svg' at line 13", + ), + # a hand-built page whose file the code names again: the `open()` write stays + ( + PYGAL.replace( + 'with open(f"plot-{THEME}.html", "wb") as f:\n f.write(chart.render())', + 'with open(f"plot-{THEME}.html", "w") as f:\n f.write("")\nprint(f"plot-{THEME}.html")', + ), + "pygal", + "security: banned-name at line 13: 'open'", + ), + ], + ) + def test_what_the_normaliser_leaves_is_blocked(self, code: str, library: str, reason: str) -> None: + result = scan_normalised(code, library) + + assert result.status == "blocked" and result.hints == [] + assert any(r.startswith(reason) for r in result.reasons), result.reasons + + def test_clean(validator_calls: list[str]) -> None: result = check('ax.plot(df["x"], df["y"], color=PAGE_BG)\nax.set_ylim(bottom=0)\nax.axhline(0)\n') diff --git a/tests/unit/agents/code/test_regions.py b/tests/unit/agents/code/test_regions.py index 1372cba3b18..7e88823b3d2 100644 --- a/tests/unit/agents/code/test_regions.py +++ b/tests/unit/agents/code/test_regions.py @@ -16,6 +16,7 @@ is_theme_assignment, is_theme_target, save_rule, + save_target, ) from agents.anyplot.code.regions import tests_theme as value_tests_theme # not named test* for pytest @@ -132,10 +133,12 @@ def test_a_theme_token_picks_fixed_values_and_a_data_dependent_conditional_is_no @pytest.mark.skipif(not CATALOGUE_FILES, reason="plots/ is not checked out") -def test_every_catalogue_theme_conditional_but_five_is_a_token() -> None: +def test_every_catalogue_theme_conditional_but_the_listed_ones_is_a_token() -> None: """Measured on 2026-10-10: 2,745 theme-testing assignments in the 650 matplotlib and seaborn files, one of them data-dependent; on 2026-10-25, 5,438 in the 1,296 files of the four libraries, five of them (a colour taken from - a palette index or another plot colour, a tuple of channel arithmetic, a list of layers).""" + a palette index or another plot colour, a tuple of channel arithmetic, a list of layers); on 2026-10-26, 8,020 + in the 1,946 files of the six libraries, eleven of them (lets-plot adds two `flavor_*()` calls, a theme call, a + tile set and two colours read from constants that test no theme).""" narrowed = [] for path in CATALOGUE_FILES: code = path.read_text(encoding="utf-8") @@ -153,6 +156,12 @@ def test_every_catalogue_theme_conditional_but_five_is_a_token() -> None: "heatmap-cohort-retention/plotnine.py:RULE", "slope-basic/altair.py:layers", "span-basic/altair.py:WARNING_LABEL_COLOR", + "bar-tornado-sensitivity/letsplot.py:flavor", + "column-stratigraphic/letsplot.py:flavor", + "heatmap-loss-triangle/letsplot.py:base_flavor", + "ks-test-comparison/letsplot.py:ribbon_color", + "map-tile-background/letsplot.py:map_tiles", + "nyquist-basic/letsplot.py:PM_LABEL_TEXT", ] @@ -242,20 +251,70 @@ def test_savefig_targets_fname_keyword_literal_and_nesting() -> None: _canvas.save(f"plot-{THEME}.png") chart.save(f"plot-{THEME}.html") """) +PYGAL = source(""" + import os + + import cairosvg + import pygal + + THEME = os.getenv("ANYPLOT_THEME", "light") + PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" + df = load_user_data() + chart = pygal.XY(width=3200, height=1800, js=[]) + chart.add("points", list(zip(df["x"], df["y"]))) + cairosvg.svg2png(bytestring=chart.render(), write_to=f"plot-{THEME}.png", output_width=3200) + chart.render_to_png(f"plot-{THEME}.png") + chart.render_to_file(f"plot-{THEME}.html") +""") +LETSPLOT = source(""" + import os + + from lets_plot import LetsPlot, aes, geom_point, ggplot, ggsave, ggsize + + LetsPlot.setup_html() + THEME = os.getenv("ANYPLOT_THEME", "light") + PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" + df = load_user_data() + plot = ggplot(df, aes(x="x", y="y")) + geom_point() + ggsize(1600, 900) + ggsave(plot, f"plot-{THEME}.png", path=".", scale=2) + ggsave(plot, filename=f"plot-{THEME}.html", path=".") +""") class TestSaveRules: """One save rule per library (`SAVE_RULES`): which calls save, where the file is, what is protected.""" def test_every_rule_names_its_save_calls(self) -> None: - assert set(SAVE_RULES) == {"matplotlib", "seaborn", "plotnine", "altair"} - assert SAVE_NAMES == {"savefig", "save", "ggsave"} + assert set(SAVE_RULES) == {"matplotlib", "seaborn", "plotnine", "altair", "pygal", "letsplot"} + assert SAVE_NAMES == {"savefig", "save", "ggsave", "render_to_png", "render_to_file", "svg2png"} assert save_rule("matplotlib").verb == "savefig" and save_rule("plotnine").verb == "save" - assert save_rule("altair").html and save_rule("altair").reopens and not save_rule("altair").canvas - assert save_rule("seaborn").canvas and not save_rule("plotnine").canvas + assert save_rule("altair").html and save_rule("altair").reopens and save_rule("altair").canvas is None + assert save_rule("seaborn").canvas == "figure" and save_rule("plotnine").canvas is None + assert save_rule("pygal").canvas == "pygal" and save_rule("letsplot").canvas == "letsplot" + assert save_rule("letsplot").directory == "." and save_rule("pygal").directory is None with pytest.raises(ValueError, match="no save rule"): save_rule("plotly") + def test_pygal_svg2png_takes_the_file_by_keyword_only(self) -> None: + regions = find_regions(PYGAL, library="pygal") + + shapes = [(c.name, c.standard_target, c.html_target) for c in regions.savefig_calls] + assert shapes == [("svg2png", True, False), ("render_to_png", True, False), ("render_to_file", False, True)] + assert regions.savefig is regions.savefig_calls[1] # the last module-level save of the PNG + rule = save_rule("pygal") + assert rule.target_position("svg2png") == -1 and rule.target_position("render_to_png") == 0 + # `svg2png`'s first positional is the SVG, never the file. + positional = ast.parse('cairosvg.svg2png(f"plot-{THEME}.png")', mode="eval").body + assert isinstance(positional, ast.Call) and save_target(positional, rule, "svg2png") is None + + def test_letsplot_ggsave_takes_the_file_second(self) -> None: + regions = find_regions(LETSPLOT, library="letsplot") + + shapes = [(c.name, c.standard_target, c.html_target) for c in regions.savefig_calls] + assert shapes == [("ggsave", True, False), ("ggsave", False, True)] + assert regions.savefig is regions.savefig_calls[0] + assert text(LETSPLOT, regions.protected[-1].span) == 'ggsave(plot, f"plot-{THEME}.png", path=".", scale=2)\n' + def test_plotnine_ggsave_takes_the_file_second(self) -> None: regions = find_regions(PLOTNINE, library="plotnine") diff --git a/tests/unit/agents/code/test_validate.py b/tests/unit/agents/code/test_validate.py index 9aec6d262a7..f662aa26de7 100644 --- a/tests/unit/agents/code/test_validate.py +++ b/tests/unit/agents/code/test_validate.py @@ -17,9 +17,12 @@ BANNED_ATTRIBUTES, BANNED_IO, ESCAPE_ATTRIBUTES, + LIBRARY_BANS, MAX_LITERAL_NUMBERS, MAX_STRING_CHARS, + MODULE_ESCAPES, PLACEHOLDER, + THEME_BINDING_MESSAGE, UNSAFE_HELPERS, UNSAFE_SUBMODULES, Finding, @@ -44,6 +47,8 @@ THEME = os.getenv("ANYPLOT_THEME", "light") df = pd.DataFrame({"a": [1, 2, 3]}) """ +THEME_LINE = 'THEME = os.getenv("ANYPLOT_THEME", "light")' +"""The theme block of every library's minimal file below.""" FOOTER = """ fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400) plt.savefig(f"plot-{THEME}.png", dpi=400) @@ -603,11 +608,16 @@ def test_other_os_use_is_a_finding(self, body: str) -> None: assert "os-use" in security_rules(body) def test_variable_named_os_without_the_import_is_not_os(self) -> None: + """Neither `os-use` nor `module-value` sees the variable; the theme block read through the alias is no theme + block (`regions.is_theme_assignment`), so its THEME is the one finding.""" code = plot("os = np.ones(3)\ntotal = os.sum()\n").replace("import os\n", "import os as _o\n") code = code.replace( 'THEME = os.getenv("ANYPLOT_THEME", "light")', 'THEME = _o.getenv("ANYPLOT_THEME", "light")' ) - assert validate_security(code, library="matplotlib") == [] + findings = validate_security(code, library="matplotlib") + assert [(f.rule, f.message, f.line) for f in findings] == [ + ("savefig-target", THEME_BINDING_MESSAGE, HEADER.splitlines().index(THEME_LINE) + 1) + ] # --- names, calls, attributes --------------------------------------------------------------- @@ -1478,8 +1488,9 @@ def test_messages_never_echo_the_setting(self) -> None: # Every public first-level submodule of a package the validator admits whole, as reviewed on 2026-10-14 # (numpy 2.5, pandas 3.0, matplotlib 3.11, seaborn 0.13, scikit-learn 1.9, statsmodels 0.15) and, by the -# import-surface survey of 2026-10-24, plotnine 0.15, mizani 0.14 and altair 6.3: it is either here, reviewed as -# computing and drawing only, or banned by name in the validator. A library upgrade that adds a submodule fails +# import-surface survey of 2026-10-24, plotnine 0.15, mizani 0.14, altair 6.3, pygal 3.1.3, cairosvg 2.9.1 and +# lets-plot 4.11.0: it is either here, reviewed as computing and drawing only, or banned by name in the validator (on +# every receiver, or on its own module in `MODULE_ESCAPES`). A library upgrade that adds a submodule fails # `test_every_submodule_is_reviewed` until someone looks at it. REVIEWED_SUBMODULES: dict[str, frozenset[str]] = { "numpy": frozenset( @@ -1534,14 +1545,30 @@ def test_messages_never_echo_the_setting(self) -> None: "mizani": frozenset("bounds breaks formatters labels palettes scale transforms typing utils".split()), # `datasets` (fetches over the network) and `jupyter` (re-exports `find_spec`) are banned by name. "altair": frozenset("expr theme typing utils vegalite".split()), + # Surveys of 2026-10-24 (pygal 3.1.3, cairosvg 2.9.1, lets-plot 4.11.0). pygal's `etree` (its ElementTree + # proxy) and `test` are banned by name; cairosvg's `css`, `features`, `image`, `parser`, `surface`, `url` and + # `test_api` on their module (`MODULE_ESCAPES`); lets-plot's `frontend_context`, `geo_data`, `settings_utils`, + # `tilesets` and the plot package's `geom_livemap_` by name. `lets_plot.plot` is pinned too: it holds the livemap. + "pygal": frozenset( + "adapters colors config formatters graph interpolate maps serie state stats style svg table util view".split() + ), + "cairosvg": frozenset("bounding_box colors defs helpers path shapes svg text".split()), + "lets_plot": frozenset("bistro export geo_data_internals mapping plot".split()), + "lets_plot.plot": frozenset( + "annotation coord core expand_limits_ facet font_features geom geom_extras geom_function_ geom_imshow_ " + "ggbunch_ ggdeck_ gggrid_ ggtb_ guide label marginal_layer plot pos sampling sandbox_ scale " + "scale_colormap_mpl scale_convenience scale_identity_ scale_position series_meta stat subplots " + "subplots_util theme_ theme_set tooltip util".split() + ), } @pytest.mark.parametrize("root", sorted(REVIEWED_SUBMODULES)) def test_every_submodule_is_reviewed(root: str) -> None: - """A public submodule is reviewed as safe, or the validator bans its name on every receiver.""" + """A public submodule is reviewed as safe, or the validator bans its name on every receiver or on its module.""" package = pytest.importorskip(root) - banned = ESCAPE_ATTRIBUTES | UNSAFE_SUBMODULES | UNSAFE_HELPERS | BANNED_ATTRIBUTES | BANNED_IO + on_module = {path.rpartition(".")[2] for path in MODULE_ESCAPES if path.rpartition(".")[0] == root} + banned = ESCAPE_ATTRIBUTES | UNSAFE_SUBMODULES | UNSAFE_HELPERS | BANNED_ATTRIBUTES | BANNED_IO | on_module found = {info.name for info in pkgutil.iter_modules(package.__path__) if not info.name.startswith("_")} unreviewed = sorted(found - REVIEWED_SUBMODULES[root] - banned) assert not unreviewed, f"review these {root} submodules and add them to REVIEWED_SUBMODULES or ban them" @@ -1608,6 +1635,10 @@ def library_plot(library: str, body: str = "") -> str: return plotnine_plot(body) if library == "altair": return altair_plot(body) + if library == "pygal": + return pygal_plot(body) + if library == "letsplot": + return letsplot_plot(body) return plot(("import seaborn as sns\n" if library == "seaborn" else "") + body) @@ -1782,6 +1813,22 @@ def test_an_argument_the_walk_cannot_read_is_refused(self, expression: str) -> N opaque = ("as string literals, not as an expression", "takes ** only from a dict literal") assert any(f.rule == "banned-call" and f.message.endswith(opaque) for f in findings), findings + @pytest.mark.parametrize( + "rebinding", + [ + pytest.param("match 'open(\"x\").read()':\n case COLUMN:\n pass\n", id="match capture"), + pytest.param("for COLUMN in ['open(\"x\").read()']:\n pass\n", id="loop target"), + pytest.param("from textwrap import dedent as COLUMN\n", id="import"), + pytest.param("def COLUMN():\n return 1\n", id="function name"), + ], + ) + def test_a_name_bound_twice_is_not_read_as_its_literal(self, rebinding: str) -> None: + """`COLUMN = "a"` and then `case COLUMN:` would hand plotnine the subject string, which the walk never saw + (the validator pass of 2026-10-26: the 1b rule counted only assignments and passed the match capture).""" + body = EXPRESSION_IMPORTS + 'COLUMN = "a"\n' + rebinding + "m = aes(x=COLUMN)\n" + findings = validate_security(plotnine_plot(body), library="plotnine") + assert any(f.message == "aes takes its column names as string literals, not as an expression" for f in findings) + def test_literal_arguments_the_walk_can_read_pass(self) -> None: body = EXPRESSION_IMPORTS + ( 'COLUMN = "a"\n' @@ -2008,6 +2055,644 @@ def test_data_transformer_that_writes_a_file_is_refused(self, body: str) -> None assert rules_of(altair_plot(body), "altair") != set() +# --- pygal and lets-plot (step 1c of agentic/specs/261024-agent-all-libraries.md) ------------------------------ + +PYGAL_HEADER = """import os + +import pygal +from pygal.style import Style + +THEME = os.getenv("ANYPLOT_THEME", "light") +PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" +INK = "#1A1A17" if THEME == "light" else "#F0EFE8" +IMPRINT = ["#009E73", "#C475FD", "#4467A3"] +""" +# The catalogue's chart: pygal's own stylesheets before an inline rule (14 files), no tooltip script (30 files). +PYGAL_CSS = 'css=("file://style.css", "file://graph.css", "inline:.axis .line { stroke-opacity: .25; }")' +PYGAL_CHART = ( + "style = Style(background=PAGE_BG, plot_background=PAGE_BG, foreground=INK, colors=IMPRINT)\n" + 'chart = pygal.XY(width=3200, height=1800, style=style, title="", js=[], ' + + PYGAL_CSS + + ', value_formatter=lambda v: f"{v:.0f}%")\n' + 'chart.add("a", [(1, 2), (3, 4)])\n' +) +PYGAL_PNG_SAVE = 'chart.render_to_png(f"plot-{THEME}.png")' +PYGAL_HTML_SAVE = 'chart.render_to_file(f"plot-{THEME}.html")' +PYGAL_FOOTER = "\n" + PYGAL_CHART + PYGAL_PNG_SAVE + "\n" + PYGAL_HTML_SAVE + "\n" +SVG2PNG = 'cairosvg.svg2png(bytestring=chart.render(), write_to=f"plot-{THEME}.png")' +LETSPLOT_HEADER = """import os + +import pandas as pd +from lets_plot import LetsPlot, aes, geom_point, ggplot, ggsize, labs, layer_tooltips +from lets_plot.export import ggsave + +LetsPlot.setup_html() + +THEME = os.getenv("ANYPLOT_THEME", "light") +df = pd.DataFrame({"a": [1, 2, 3]}) +""" +LETSPLOT_PLOT = ( + 'plot = ggplot(df, aes(x="a", y="a")) + geom_point(tooltips=layer_tooltips().line("@a").format("@a", ".1f"))' + ' + labs(title="") + ggsize(1600, 900)\n' +) +LETSPLOT_PNG_SAVE = 'ggsave(plot, f"plot-{THEME}.png", path=".", scale=2)' +LETSPLOT_HTML_SAVE = 'ggsave(plot, f"plot-{THEME}.html", path=".")' +LETSPLOT_FOOTER = "\n" + LETSPLOT_PLOT + LETSPLOT_PNG_SAVE + "\n" + LETSPLOT_HTML_SAVE + "\n" + + +def pygal_plot(body: str = "") -> str: + """A minimal valid pygal file in the catalogue's shape, PNG and HTML saves included, with `body` before the chart.""" + return PYGAL_HEADER + body + PYGAL_FOOTER + + +def letsplot_plot(body: str = "") -> str: + """A minimal valid lets-plot file in the catalogue's normalised shape, with `body` before the plot.""" + return LETSPLOT_HEADER + body + LETSPLOT_FOOTER + + +PYGAL_ROUTES = [ + pytest.param("t = chart.render_tree()\n", {"banned-attribute"}, id="render_tree hands out the live tree"), + pytest.param("chart.render_in_browser()\n", {"banned-attribute"}, id="render_in_browser"), + pytest.param("t = chart.render_table()\n", {"banned-attribute"}, id="render_table"), + pytest.param("r = chart.render_response()\n", {"banned-attribute"}, id="render_response"), + pytest.param("chart.add_xml_filter(lambda tree: tree)\n", {"banned-attribute"}, id="add_xml_filter"), + pytest.param('x = pygal.util.cut([IMPRINT], "copy")\n', {"banned-attribute"}, id="cut, a getattr by string"), + pytest.param("from pygal.util import template\n", {"banned-attribute"}, id="template, a str.format"), + pytest.param("e = pygal.entry_points\n", {"banned-attribute"}, id="plugin entry points"), + pytest.param("e = pygal.etree\n", {"banned-attribute"}, id="ElementTree proxy"), + pytest.param("e = pygal.svg.etree\n", {"banned-attribute"}, id="ElementTree proxy re-exported"), + pytest.param("from pygal.etree import etree\n", {"banned-import", "banned-attribute"}, id="from pygal.etree"), + pytest.param('chart.config._update({"js": ["x.js"]})\n', {"banned-attribute"}, id="config merge"), + pytest.param("import cairosvg\ns = cairosvg.SURFACES\n", {"banned-attribute"}, id="SURFACES"), + pytest.param( + 'import cairosvg\ncairosvg.svg2pdf(bytestring=b"", write_to="x.pdf")\n', {"banned-attribute"}, id="svg2pdf" + ), + pytest.param("import cairosvg\nc = cairosvg.surface.cairo\n", {"banned-attribute"}, id="cairocffi in surface"), + pytest.param("import cairosvg\nf = cairosvg.helpers.cairo.ffi\n", {"banned-attribute"}, id="cairocffi in helpers"), + pytest.param("from cairosvg.surface import PNGSurface\n", {"banned-import", "banned-attribute"}, id="a surface"), + pytest.param("import cairosvg.parser\n", {"banned-import"}, id="parser import"), + pytest.param('import cairosvg\nb = cairosvg.url.fetch("x")\n', {"banned-attribute"}, id="url fetch"), + pytest.param('import cairosvg\np = cairosvg.Path("x")\n', {"banned-attribute"}, id="re-exported Path"), + pytest.param("import cairosvg\np = cairosvg.ROOT.parent\n", {"banned-attribute"}, id="ROOT, a Path instance"), + pytest.param("import cairosvg\ni = cairosvg.image.Image\n", {"banned-attribute"}, id="PIL in image"), + pytest.param("import cairosvg\nc = cairosvg.css.cssselect2\n", {"banned-attribute"}, id="cssselect2 in css"), + pytest.param("import cairosvg\nc = [cairosvg.colors][0].tinycss2\n", {"banned-attribute"}, id="tinycss2 by name"), + pytest.param("import cairosvg\nt = cairosvg.test_api\n", {"banned-attribute"}, id="test_api"), +] +# Each pygal save name writes one kind of file (`SaveRule.writes`; the correctness review of 2026-10-26): the name in +# its own save's place passes, and the same name in the HTML save's place writing the other file is a finding. +PYGAL_FILE_TYPES = [ + pytest.param( + PYGAL_PNG_SAVE, + PYGAL_PNG_SAVE, + 'chart.render_to_png(f"plot-{THEME}.html")', + 'render_to_png writes only f"plot-{THEME}.png"', + id="render_to_png", + ), + pytest.param( + PYGAL_PNG_SAVE, + "import cairosvg\n" + SVG2PNG, + 'cairosvg.svg2png(bytestring=chart.render(), write_to=f"plot-{THEME}.html")', + 'svg2png writes only f"plot-{THEME}.png"', + id="svg2png", + ), + pytest.param( + PYGAL_HTML_SAVE, + PYGAL_HTML_SAVE, + 'chart.render_to_file(f"plot-{THEME}.png")', + 'render_to_file writes only f"plot-{THEME}.html"', + id="render_to_file", + ), +] +PYGAL_NEAR_ROUTES = [ + pytest.param("svg = chart.render()\n", id="render"), + pytest.param("svg = chart.render(is_unicode=True)\n", id="render as text"), + pytest.param("uri = chart.render_data_uri()\n", id="render_data_uri"), + pytest.param('chart.x_labels = ["a", "b"]\n', id="labels"), + pytest.param('style2 = Style(font_family="DejaVu Sans, sans-serif")\n', id="a local font"), + pytest.param("from pygal.formatters import Significant\nf = Significant()\n", id="a formatter"), + pytest.param("import cairosvg\nv = cairosvg.VERSION\n", id="cairosvg's version string"), +] +PYGAL_CONFIG_ROUTES = [ + pytest.param('c2 = pygal.Bar(js=["//cdn.example/x.js"])\n', {"banned-keyword"}, id="a script fetched"), + pytest.param('c2 = pygal.Bar(js=["file:///etc/passwd"])\n', {"banned-keyword", "url-literal"}, id="a script read"), + pytest.param('c2 = pygal.Bar(css=["file:///etc/passwd"])\n', {"banned-keyword", "url-literal"}, id="a file read"), + pytest.param('c2 = pygal.Bar(css=["fi" + "le:///etc/passwd"])\n', {"banned-keyword"}, id="a built entry"), + pytest.param('p = "x"\nc2 = pygal.Bar(css=[f"{p}inline:a"])\n', {"banned-keyword"}, id="f-string head"), + pytest.param('c2 = pygal.Bar(css="inline:a")\n', {"banned-keyword"}, id="not a list"), + pytest.param('C = ["inline:a"]\nC = ["x"]\nc2 = pygal.Bar(css=C)\n', {"banned-keyword"}, id="name bound twice"), + pytest.param( + 'C = ["inline:a"]\nC.append("fi" + "le:///x")\nc2 = pygal.Bar(css=C)\n', {"banned-keyword"}, id="list mutated" + ), + pytest.param('c2 = pygal.Bar(defs=[""])\n', {"banned-keyword"}, id="defs"), + pytest.param('chart.defs = [""]\n', {"banned-attribute"}, id="defs attribute"), + pytest.param('chart.css = ["fi" + "le:///x"]\n', {"banned-keyword"}, id="attribute with a built entry"), + pytest.param('chart.css += ["fi" + "le:///x"]\n', {"banned-attribute"}, id="augmented"), + pytest.param('chart.css.append("inline:a")\n', {"banned-attribute"}, id="mutator"), + pytest.param('chart.css[0] = "inline:a"\n', {"banned-attribute"}, id="item store"), + pytest.param("c = chart.css\n", {"banned-attribute"}, id="read"), + pytest.param("del chart.js\n", {"banned-attribute"}, id="delete"), + pytest.param("chart.js, n = [], 1\n", {"banned-attribute"}, id="unpacked target"), + pytest.param('o = {}\no["c" + "ss"] = ["x"]\nc2 = pygal.Bar(**o)\n', {"banned-keyword"}, id="** built"), + pytest.param('c2 = pygal.Bar(**{"css": ["fi" + "le:///x"]})\n', {"banned-keyword"}, id="** literal, bad css"), + pytest.param('c2 = pygal.Bar(**{"c" + "ss": ["x"]})\n', {"banned-keyword"}, id="** built key"), + pytest.param('O = {"width": 800}\nO["js"] = ["x"]\nc2 = pygal.Bar(**O)\n', {"banned-keyword"}, id="** mutated"), + pytest.param('c2 = pygal.Bar(**{**{"js": []}})\n', {"banned-keyword"}, id="** nested unpacking"), + pytest.param('cfg = pygal.Config(js=["x.js"])\n', {"banned-keyword"}, id="Config"), + pytest.param('c2 = chart(css=["x.css"])\n', {"banned-keyword"}, id="a chart called with options"), + pytest.param('s = Style(font_family="googlefont:Roboto")\n', {"url-literal"}, id="Google font"), + pytest.param('f = "Roboto"\ns = Style(title_font_family=f"googlefont:{f}")\n', {"url-literal"}, id="font f-string"), + pytest.param('s = "file://style.css"\n', {"url-literal"}, id="a package stylesheet outside a css list"), +] +PYGAL_CONFIG_SIBLINGS = [ + pytest.param("c2 = pygal.Bar(js=())\n", id="js empty tuple"), + pytest.param('c2 = pygal.Bar(js=[], css=["inline:.a { fill: red; }"])\n', id="inline css"), + pytest.param( + 'CSS = ("file://style.css", "file://graph.css", "inline:.a { fill: red; }")\n' + "c2 = pygal.Bar(js=[], css=CSS)\nc3 = pygal.Line(css=CSS)\n", + id="a css list bound once", + ), + pytest.param( + 'EXTRA = "inline:.b { opacity: .5; }"\nc2 = pygal.Bar(css=["file://base.css", EXTRA])\n', + id="an entry bound once", + ), + pytest.param('x = 2\nc2 = pygal.Bar(css=[f"inline:.a {{ stroke-width: {x}; }}"])\n', id="inline f-string"), + pytest.param('chart.css = ["file://style.css", "inline:.c { fill: none; }"]\n', id="css attribute"), + pytest.param("chart.js = []\n", id="js attribute"), + pytest.param( + 'OPTIONS = {"width": 3200, "height": 1800, "js": []}\nc2 = pygal.Bar(**OPTIONS, title="")\n' + "c3 = pygal.Line(**OPTIONS)\n", + id="** a dict bound once", + ), + pytest.param('c2 = pygal.Bar(**{"show_legend": False})\n', id="** a dict literal"), +] +SVG_NAMESPACE_LOOKALIKES = [ + pytest.param("http://www.w3.org/2000/svgevil.example/x", id="name continued"), + pytest.param("http://www.w3.org/2000/svg/../x", id="path continued"), + pytest.param("http://www.w3.org/2000/svg http://evil.example", id="a second URL"), + pytest.param("https://www.w3.org/2000/svg", id="https"), + pytest.param("HTTP://WWW.W3.ORG/2000/SVG", id="upper case"), + pytest.param("{http://www.w3.org/2000/svg.x}a", id="Clark name continued"), + pytest.param("http://www.w3.org/1999/xlinks", id="xlink continued"), +] + + +class TestPygalProfile: + """pygal: pygal and cairosvg only, a PNG and an HTML save, pygal's config and its SVG namespaces.""" + + def test_minimal_file_has_no_findings(self) -> None: + assert validate_security(pygal_plot(), library="pygal") == [] + + @pytest.mark.parametrize( + "body", + [ + "import cairosvg\n", + "from cairosvg import svg2png\n", + "import pygal.style\n", + "from pygal import formatters\n", + "import numpy as np\n", + "from scipy import stats\n", + ], + ) + def test_allowed_import(self, body: str) -> None: + assert rules_of(pygal_plot(body), "pygal") == set() + + @pytest.mark.parametrize( + "body", + [ + "import xml.etree.ElementTree as ET\n", + "from xml.etree import ElementTree\n", + "from PIL import Image\n", + "import io\n", + "import cairocffi\n", + "import cssselect2\n", + "import matplotlib.colors\n", + "import pygal.etree\n", + "import cairosvg.url as url\n", + "from pygal_maps_world.maps import World\n", + "import lets_plot\n", + ], + ) + def test_banned_import(self, body: str) -> None: + assert "banned-import" in rules_of(pygal_plot(body), "pygal") + + @pytest.mark.parametrize( + ("old", "new"), + [ + pytest.param(PYGAL_PNG_SAVE, 'chart.render_to_png(filename=f"plot-{THEME}.png", dpi=96)', id="filename="), + pytest.param(PYGAL_PNG_SAVE, "import cairosvg\n" + SVG2PNG, id="svg2png"), + pytest.param( + PYGAL_PNG_SAVE, + "import cairosvg\n" + 'cairosvg.svg2png(bytestring=chart.render(), write_to=f"plot-{THEME}.png", output_width=3200, ' + "output_height=1800, dpi=96, scale=1, background_color=PAGE_BG)", + id="svg2png sized", + ), + pytest.param( + PYGAL_PNG_SAVE, + 'from cairosvg import svg2png\nsvg2png(bytestring=chart.render(), write_to=f"plot-{THEME}.png")', + id="svg2png imported", + ), + pytest.param(PYGAL_HTML_SAVE, 'chart.render_to_file(filename=f"plot-{THEME}.html")', id="HTML filename="), + pytest.param(PYGAL_HTML_SAVE, "", id="no HTML"), + ], + ) + def test_save_passes(self, old: str, new: str) -> None: + assert validate_security(swapped(pygal_plot(), old, new), library="pygal") == [] + + @pytest.mark.parametrize( + ("old", "new"), + [ + pytest.param(PYGAL_PNG_SAVE, 'chart.render_to_png("plot.png")', id="other file"), + pytest.param(PYGAL_PNG_SAVE, 'chart.render_to_png(f"plot-{THEME}.svg")', id="other type"), + pytest.param(PYGAL_PNG_SAVE, 'chart.render_to_png(f"plot-{THEME}.png", 300)', id="dpi by position"), + pytest.param(PYGAL_PNG_SAVE, 'chart.render_to_png(f"plot-{THEME}.png", width=800)', id="chart options"), + pytest.param(PYGAL_PNG_SAVE, 'chart.render_to_png(f"plot-{THEME}.png", **{})', id="unpacked keywords"), + pytest.param(PYGAL_PNG_SAVE, "data = chart.render_to_png()\n" + PYGAL_PNG_SAVE, id="bytes, no file"), + pytest.param(PYGAL_HTML_SAVE, 'chart.render_to_file(f"plot-{THEME}.svg")', id="SVG file"), + pytest.param(PYGAL_HTML_SAVE, 'chart.render_to_file(f"plot-{THEME}.html", pretty_print=True)', id="opts"), + pytest.param( + PYGAL_PNG_SAVE, + 'import cairosvg\ncairosvg.svg2png(chart.render(), write_to=f"plot-{THEME}.png")', + id="svg2png by position", + ), + pytest.param( + PYGAL_PNG_SAVE, "import cairosvg\ncairosvg.svg2png(bytestring=chart.render())", id="svg2png bytes" + ), + pytest.param( + PYGAL_PNG_SAVE, + "import cairosvg\ncairosvg.svg2png(bytestring=chart.render(), write_to=handle)", + id="svg2png into a file object", + ), + pytest.param( + PYGAL_PNG_SAVE, + 'import cairosvg\ncairosvg.svg2png(bytestring=chart.render(), write_to=f"plot-{THEME}.png", parent=1)', + id="svg2png keyword it does not list", + ), + ], + ) + def test_other_save_is_a_finding(self, old: str, new: str) -> None: + assert rules_of(swapped(pygal_plot(), old, new), "pygal") == {"savefig-target"} + + def test_svg2png_takes_no_positional(self) -> None: + new = 'import cairosvg\ncairosvg.svg2png(chart.render(), write_to=f"plot-{THEME}.png")' + findings = validate_security(swapped(pygal_plot(), PYGAL_PNG_SAVE, new), library="pygal") + assert [f.message for f in findings] == ["svg2png takes every argument by keyword"] + + @pytest.mark.parametrize("keyword", ['url="plot.svg"', "file_obj=handle", "unsafe=True", "url_fetcher=fetcher"]) + def test_svg2png_input_keywords_are_refused(self, keyword: str) -> None: + """`url=` and `file_obj=` read the main input from any path or URL, `unsafe=` and `url_fetcher=` open hrefs.""" + new = f'import cairosvg\ncairosvg.svg2png(bytestring=chart.render(), write_to=f"plot-{{THEME}}.png", {keyword})' + assert rules_of(swapped(pygal_plot(), PYGAL_PNG_SAVE, new), "pygal") == {"banned-keyword", "savefig-target"} + + def test_a_file_that_saves_no_png_is_a_finding(self) -> None: + findings = validate_security(swapped(pygal_plot(), PYGAL_PNG_SAVE, ""), library="pygal") + assert [(f.rule, f.message, f.line) for f in findings] == [("savefig-target", NO_PNG_SAVE, None)] + + @pytest.mark.parametrize(("place", "right", "wrong", "message"), PYGAL_FILE_TYPES) + def test_a_save_name_writes_only_its_own_file_type(self, place: str, right: str, wrong: str, message: str) -> None: + code = swapped(pygal_plot(), place, right) + assert validate_security(code, library="pygal") == [] + wrong_code = swapped(code, PYGAL_HTML_SAVE, wrong) + findings = validate_security(wrong_code, library="pygal") + line = wrong_code.splitlines().index(wrong) + 1 + assert [(f.rule, f.message, f.line) for f in findings] == [("savefig-target", message, line)] + + def test_svg_text_in_the_png_is_no_png_save(self) -> None: + """`render_to_file` writes SVG text, so a file whose one PNG save is `render_to_file` writes no PNG.""" + new = 'chart.render_to_file(f"plot-{THEME}.png")' + findings = validate_security(swapped(pygal_plot(), PYGAL_PNG_SAVE, new), library="pygal") + assert [(f.rule, f.message) for f in findings] == [ + ("savefig-target", 'render_to_file writes only f"plot-{THEME}.html"'), + ("savefig-target", NO_PNG_SAVE), + ] + + @pytest.mark.parametrize( + ("body", "expected"), + [ + ("import matplotlib.pyplot as plt\nplt.savefig(f'plot-{THEME}.png')\n", {"banned-import", "banned-call"}), + ("canvas.save(f'plot-{THEME}.png')\n", {"banned-call"}), + ], + ) + def test_other_libraries_saves_are_banned_calls(self, body: str, expected: set[str]) -> None: + """PIL's `.save` pads the PNG in 13 catalogue files; with pygal it is no save of the plot.""" + findings = validate_security(pygal_plot(body), library="pygal") + assert {f.rule for f in findings} == expected + assert any(f.message.endswith("; the plot is saved with save") for f in findings if f.rule == "banned-call") + + @pytest.mark.parametrize(("body", "expected"), PYGAL_ROUTES) + def test_route_is_refused(self, body: str, expected: set[str]) -> None: + assert rules_of(pygal_plot(body), "pygal") == expected + + @pytest.mark.parametrize("body", PYGAL_NEAR_ROUTES) + def test_nearest_allowed_sibling_passes(self, body: str) -> None: + assert rules_of(pygal_plot(body), "pygal") == set() + + @pytest.mark.parametrize(("body", "expected"), PYGAL_CONFIG_ROUTES) + def test_config_that_reads_fetches_or_changes_later_is_refused(self, body: str, expected: set[str]) -> None: + assert rules_of(pygal_plot(body), "pygal") == expected + + @pytest.mark.parametrize("body", PYGAL_CONFIG_SIBLINGS) + def test_config_the_rule_can_read_passes(self, body: str) -> None: + assert rules_of(pygal_plot(body), "pygal") == set() + + def test_svg_namespaces_pass_in_pygal_only(self) -> None: + body = ( + 'NS = {"svg": "http://www.w3.org/2000/svg", "xlink": "http://www.w3.org/1999/xlink"}\n' + 'TAG = "{http://www.w3.org/2000/svg}rect"\n' + "MARKUP = ''\n" + ) + assert rules_of(pygal_plot(body), "pygal") == set() + assert rules_of(library_plot("matplotlib", body), "matplotlib") == {"url-literal"} + + @pytest.mark.parametrize("text", SVG_NAMESPACE_LOOKALIKES) + def test_a_namespace_lookalike_is_a_url(self, text: str) -> None: + assert rules_of(pygal_plot(f"u = {text!r}\n"), "pygal") == {"url-literal"} + + def test_cut_is_pandas_with_the_other_libraries(self) -> None: + """`cut` is banned in pygal's profile only: `pygal.util.cut` is a getattr by string, `pd.cut` bins values.""" + body = 'bins = pd.cut(df["a"], 3)\n' + assert rules_of(library_plot("matplotlib", body), "matplotlib") == set() + assert rules_of(pygal_plot("import pandas as pd\n" + body), "pygal") == {"banned-attribute"} + + +LETSPLOT_ROUTES = [ + pytest.param('plot.to_png("x.png")\n', {"banned-attribute"}, id="to_png"), + pytest.param('plot.to_svg("x.svg")\n', {"banned-attribute"}, id="to_svg"), + pytest.param('plot.to_pdf("x.pdf")\n', {"banned-attribute"}, id="to_pdf"), + pytest.param('plot.to_html("x.html")\n', {"banned-call"}, id="to_html"), + pytest.param("plot.show()\n", {"banned-attribute"}, id="show"), + pytest.param("p = geom_point().props()\n", {"banned-attribute"}, id="props hands out the spec dict"), + pytest.param('LetsPlot.setup_show_ext(exec="sh -c id")\n', {"banned-attribute", "banned-call"}, id="browser"), + pytest.param('LetsPlot.set({"js_url_manual": "x"})\n', {"banned-attribute", "banned-call"}, id="LetsPlot.set"), + pytest.param("LetsPlot.set_theme(None)\n", {"banned-call"}, id="another LetsPlot method"), + pytest.param("LetsPlot.setup_html(offline=True)\n", {"banned-call"}, id="setup_html with arguments"), + pytest.param("lp = LetsPlot\n", {"banned-call"}, id="LetsPlot as a value"), + pytest.param("import lets_plot\n[lets_plot.LetsPlot][0].set({})\n", {"banned-call"}, id="LetsPlot in a list"), + pytest.param("import lets_plot\nc = lets_plot.cfg\n", {"banned-attribute"}, id="cfg"), + pytest.param("from lets_plot import cfg\n", {"banned-attribute"}, id="from lets_plot import cfg"), + pytest.param( + 'import lets_plot.plot.core as core\nm = core.LazyModule("subprocess")\n', {"banned-attribute"}, id="LazyModule" + ), + pytest.param( + 'import lets_plot.plot.geom_imshow_ as gi\nr = gi.png.Reader(filename="x")\n', + {"banned-attribute"}, + id="png proxy", + ), + pytest.param( + "import lets_plot.plot.geom_imshow_ as gi\np = gi.palettable\n", {"banned-attribute"}, id="palettable" + ), + pytest.param("import lets_plot.plot.geom as g\np = g.geopandas\n", {"banned-attribute"}, id="geopandas proxy"), + pytest.param("import lets_plot\np = lets_plot.extend_path\n", {"banned-attribute"}, id="pkgutil.extend_path"), + pytest.param("from lets_plot import geom_livemap\n", {"banned-attribute"}, id="livemap"), + pytest.param("from lets_plot import maptiles_zxy\n", {"banned-attribute"}, id="map tiles"), + pytest.param("import lets_plot\ng = lets_plot.geo_data\n", {"banned-attribute"}, id="geo_data by attribute"), +] +LETSPLOT_NEAR_ROUTES = [ + pytest.param('from lets_plot import scale_y_continuous\ns = scale_y_continuous(format="{.0f} %")\n', id="format="), + pytest.param( + 'from lets_plot import layer_labels\nl = layer_labels().line("@a").format("@a", "d").size(10)\n', id="labels" + ), + pytest.param( + 'from lets_plot import smooth_labels\nl = smooth_labels().eq(lhs="y").format("^R2", ".2f").label_x("left")\n', + id="smooth labels", + ), + pytest.param( + 't = layer_tooltips(["a"]).format(field="@a", format=".0f").title("x").anchor("top_right").min_width(50)\n', + id="tooltips by keyword", + ), + pytest.param("d = layer_tooltips().as_dict()\n", id="as_dict"), + pytest.param('from lets_plot.mapping import as_discrete\nm = as_discrete("a")\n', id="mapping"), + pytest.param("from lets_plot import gggrid\ng = gggrid([ggplot(df)])\n", id="gggrid"), +] +LETSPLOT_FORMAT_ROUTES = [ + pytest.param('t = "{0}".format(1)\n', id="a string's format"), + pytest.param('tt = layer_tooltips()\nt = tt.format("@a", ".1f")\n', id="a builder bound to a name"), + pytest.param('layer_tooltips = str\nt = layer_tooltips("{0}").format("@a")\n', id="the builder name rebound"), + pytest.param( + 'match str:\n case layer_tooltips:\n pass\nt = layer_tooltips("{0}").format("@a")\n', + id="match capture", + ), + pytest.param('def f(layer_tooltips):\n return layer_tooltips("{0}").format("@a")\n', id="parameter"), + pytest.param( + 'import lets_plot\nlets_plot.layer_tooltips = str\nt = layer_tooltips("{0}").format("@a")\n', + id="the library's attribute replaced", + ), + pytest.param('v = "@a"\nt = layer_tooltips().format(v, ".1f")\n', id="a non-literal argument"), + pytest.param('t = layer_tooltips().format(*["@a"])\n', id="starred"), + pytest.param('t = layer_tooltips().format("@a", spec=".1f")\n', id="another keyword"), + pytest.param('t = layer_tooltips().as_dict().format("a")\n', id="as_dict in the chain"), + pytest.param('t = layer_tooltips().line("@a").upper().format("a")\n', id="a method outside the builder's"), + pytest.param('import lets_plot as lp\nt = lp.layer_tooltips().format("@a")\n', id="through the module"), + pytest.param( + 'from lets_plot.plot.tooltip import layer_tooltips as lt\nt = lt().format("@a")\n', id="another import path" + ), + pytest.param('f = layer_tooltips().format\nt = f("@a")\n', id="not called"), +] + + +class TestLetsplotProfile: + """lets-plot: lets_plot only, `ggsave` into `.`, `LetsPlot.setup_html()`, the builders' `.format`.""" + + def test_minimal_file_has_no_findings(self) -> None: + assert validate_security(letsplot_plot(), library="letsplot") == [] + + @pytest.mark.parametrize( + "body", + [ + "import lets_plot\n", + "from lets_plot import ggsave\n", + "from lets_plot.mapping import as_discrete\n", + "from lets_plot.bistro.corr import corr_plot\n", + "import numpy as np\n", + "from scipy import stats\n", + ], + ) + def test_allowed_import(self, body: str) -> None: + assert rules_of(letsplot_plot(body), "letsplot") == set() + + @pytest.mark.parametrize( + "body", + [ + "from lets_plot.geo_data import geocode_countries\n", + "import lets_plot.geo_data\n", + "from lets_plot import tilesets\n", + "import lets_plot.settings_utils\n", + "import lets_plot.frontend_context\n", + "from lets_plot.plot.geom_livemap_ import geom_livemap\n", + "import matplotlib.pyplot as plt\n", + "from PIL import Image\n", + "import shutil\n", + "import pygal\n", + ], + ) + def test_banned_import(self, body: str) -> None: + assert "banned-import" in rules_of(letsplot_plot(body), "letsplot") + + def test_star_import_is_refused(self) -> None: + """The normaliser expands `from lets_plot import *` (151 catalogue files); left in, it is a finding.""" + assert rules_of(letsplot_plot("from lets_plot import *\n"), "letsplot") == {"star-import"} + + @pytest.mark.parametrize( + ("old", "new"), + [ + pytest.param( + LETSPLOT_PNG_SAVE, 'ggsave(plot, filename=f"plot-{THEME}.png", path=".", scale=4)', id="file=" + ), + pytest.param( + LETSPLOT_PNG_SAVE, + 'ggsave(plot, f"plot-{THEME}.png", path=".", w=3200, h=1800, unit="px", dpi=96)', + id="pixel size", + ), + pytest.param(LETSPLOT_PNG_SAVE, 'ggsave(plot, f"plot-{THEME}.png", path=".")', id="no scale"), + pytest.param(LETSPLOT_HTML_SAVE, 'ggsave(plot, filename=f"plot-{THEME}.html", path=".")', id="HTML file="), + pytest.param(LETSPLOT_HTML_SAVE, "", id="no HTML"), + ], + ) + def test_save_passes(self, old: str, new: str) -> None: + assert validate_security(swapped(letsplot_plot(), old, new), library="letsplot") == [] + + @pytest.mark.parametrize( + ("old", "new"), + [ + pytest.param(LETSPLOT_PNG_SAVE, 'ggsave(plot, f"plot-{THEME}.png", scale=2)', id="no path"), + pytest.param(LETSPLOT_PNG_SAVE, 'ggsave(plot, f"plot-{THEME}.png", path="/tmp")', id="another path"), + pytest.param(LETSPLOT_PNG_SAVE, 'ggsave(plot, f"plot-{THEME}.png", path=str(d))', id="computed path"), + pytest.param(LETSPLOT_HTML_SAVE, 'ggsave(plot, f"plot-{THEME}.html")', id="HTML without a path"), + pytest.param(LETSPLOT_HTML_SAVE, 'ggsave(plot, f"plot-{THEME}.svg", path=".")', id="SVG"), + pytest.param(LETSPLOT_PNG_SAVE, 'ggsave(plot, "plot.png", path=".")', id="old-form file"), + pytest.param(LETSPLOT_PNG_SAVE, 'ggsave(plot, f"plot-{THEME}.png", ".")', id="path by position"), + pytest.param(LETSPLOT_HTML_SAVE, 'ggsave(plot, f"plot-{THEME}.html", path=".", iframe=False)', id="if"), + pytest.param(LETSPLOT_PNG_SAVE, 'ggsave(plot, f"plot-{THEME}.png", **{"path": "."})', id="unpacked"), + pytest.param(LETSPLOT_PNG_SAVE, 'ggsave(plot=plot, filename=f"plot-{THEME}.png", path=".")', id="plot="), + ], + ) + def test_other_save_is_a_finding(self, old: str, new: str) -> None: + assert rules_of(swapped(letsplot_plot(), old, new), "letsplot") == {"savefig-target"} + + def test_a_save_without_the_directory_names_it(self) -> None: + """Without `path=`, lets-plot creates `./lets-plot-images` and writes there (the render guard refuses it).""" + code = swapped(letsplot_plot(), LETSPLOT_PNG_SAVE, 'ggsave(plot, f"plot-{THEME}.png", scale=2)') + assert [f.message for f in validate_security(code, library="letsplot")] == ['ggsave must save with path="."'] + + def test_a_file_that_saves_no_png_is_a_finding(self) -> None: + findings = validate_security(swapped(letsplot_plot(), LETSPLOT_PNG_SAVE, ""), library="letsplot") + assert [(f.rule, f.message, f.line) for f in findings] == [("savefig-target", NO_PNG_SAVE, None)] + + @pytest.mark.parametrize(("body", "expected"), LETSPLOT_ROUTES) + def test_route_is_refused(self, body: str, expected: set[str]) -> None: + assert rules_of(letsplot_plot(body), "letsplot") == expected + + @pytest.mark.parametrize("body", LETSPLOT_NEAR_ROUTES) + def test_nearest_allowed_sibling_passes(self, body: str) -> None: + assert rules_of(letsplot_plot(body), "letsplot") == set() + + @pytest.mark.parametrize("body", LETSPLOT_FORMAT_ROUTES) + def test_format_outside_a_builder_chain_is_refused(self, body: str) -> None: + findings = validate_security(letsplot_plot(body), library="letsplot") + assert ("banned-attribute", "attribute '.format' is not allowed") in {(f.rule, f.message) for f in findings} + + def test_a_dunder_in_a_builder_format_still_fails(self) -> None: + body = 't = layer_tooltips().format("@a", "{0.__class__}")\n' + assert rules_of(letsplot_plot(body), "letsplot") == {"dunder"} + + def test_the_builder_exemption_is_lets_plots(self) -> None: + """In another profile the same chain is an ordinary `.format`.""" + body = 'from plotnine import labs as layer_tooltips\nt = layer_tooltips().format("@a", ".1f")\n' + assert "banned-attribute" in rules_of(plotnine_plot(body), "plotnine") + + +# The save rule finds a save by its callee's name, so a save reached under another name escapes it: open in the 1b +# plotnine profile (the lets-plot draft of 2026-10-26, alias_probe.py). One save name per profile, its import and +# a call that writes elsewhere. +SAVE_NAME_CASES = [ + pytest.param("plotnine", "from plotnine import ggsave\n", "ggsave", 'plot, "/tmp/x.pdf"', id="plotnine"), + pytest.param( + "pygal", "from cairosvg import svg2png\n", "svg2png", 'bytestring=b"", write_to="/tmp/x.png"', id="pygal" + ), + pytest.param("letsplot", "", "ggsave", 'plot, "/tmp/x.pdf", path="/tmp"', id="letsplot"), +] +SAVE_NAME_ROUTES = [ + pytest.param("from {module} import {name} as keep\nkeep({args})\n", {"banned-call"}, id="imported as"), + pytest.param("from textwrap import dedent as {name}\n", {"banned-call"}, id="imported under a save name"), + pytest.param("g = {name}\ng({args})\n", {"banned-call"}, id="assigned"), + pytest.param("[{name}][0]({args})\n", {"banned-call"}, id="in a list"), + pytest.param("import functools\nfunctools.partial({name})({args})\n", {"banned-call"}, id="partial"), + # The wrapper calls the save where it is named, so the save rule sees its starred target (pygal's profile also + # refuses a `**k` it cannot read, so the wrapper forwards positionals only). + pytest.param("w = lambda *a: {name}(*a)\nw({args})\n", {"savefig-target"}, id="lambda wrapper"), + pytest.param("g = getattr(df, '{name}')\n", {"banned-name"}, id="getattr"), +] + + +@pytest.mark.parametrize(("library", "imports", "name", "args"), SAVE_NAME_CASES) +@pytest.mark.parametrize(("template", "expected"), SAVE_NAME_ROUTES) +def test_a_save_reached_under_another_name_is_refused( + library: str, imports: str, name: str, args: str, template: str, expected: set[str] +) -> None: + module = {"plotnine": "plotnine", "pygal": "cairosvg", "letsplot": "lets_plot.export"}[library] + body = imports + template.format(module=module, name=name, args=args) + assert rules_of(library_plot(library, body), library) == expected + + +@pytest.mark.parametrize( + ("library", "body"), + [ + pytest.param("plotnine", "from plotnine import ggsave as ggsave\n", id="plotnine, imported as itself"), + pytest.param( + "pygal", + 'from cairosvg import svg2png\nsvg2png(bytestring=chart.render(), write_to=f"plot-{THEME}.png")\n', + id="pygal, called where named", + ), + pytest.param("letsplot", "from lets_plot import ggsave\n", id="letsplot, imported twice under its name"), + ], +) +def test_a_save_name_under_its_own_name_passes(library: str, body: str) -> None: + assert rules_of(library_plot(library, body), library) == set() + + +def test_a_save_name_in_a_plotnine_expression_is_refused() -> None: + """plotnine evaluates the string in the plot's namespace, where `ggsave` is bound.""" + body = "from plotnine import ggsave\nm = aes(x=\"(lambda g: g(df, '/tmp/x.pdf'))(ggsave)\")\n" + findings = validate_security(plotnine_plot(body), library="plotnine") + assert [(f.rule, f.message) for f in findings] == [ + ("banned-call", "in the aes expression: 'ggsave' may only be called directly") + ] + + +LIBRARY_ROOTS = {"altair": "altair", "pygal": "pygal", "letsplot": "lets_plot"} + + +@pytest.mark.parametrize( + ("library", "name"), [(library, name) for library, names in sorted(LIBRARY_BANS.items()) for name in sorted(names)] +) +def test_a_library_ban_is_refused_on_any_receiver_and_as_an_import(library: str, name: str) -> None: + for body in (f"x = obj.{name}\n", f"from {LIBRARY_ROOTS[library]} import {name}\n"): + assert rules_of(library_plot(library, body), library) == {"banned-attribute"}, body + + +LETSPLOT_HELPERS = ( + "to_png to_svg to_pdf setup_show_ext WebBrHtmlPageContext LazyModule geom_livemap maptiles_zxy maptiles_lets_plot " + "maptiles_solid maptiles_chessboard geocoding_service geocode geocode_cities geocode_counties geocode_states " + "geocode_countries reverse_geocode get_geocodes get_centroids get_boundaries get_limits extend_path".split() +) + + +@pytest.mark.parametrize("library", ["letsplot", "matplotlib"]) +@pytest.mark.parametrize("name", LETSPLOT_HELPERS) +def test_a_lets_plot_helper_is_refused_in_every_profile(library: str, name: str) -> None: + """No catalogue file of another library uses these names, so they are banned everywhere (`UNSAFE_HELPERS`).""" + assert rules_of(library_plot(library, f"x = obj.{name}\n"), library) == {"banned-attribute"} + + +def test_a_save_name_as_a_value_is_refused_in_plot_and_data_code() -> None: + findings = validate_security(plot("s = savefig\n"), library="matplotlib") + assert [(f.rule, f.message) for f in findings] == [("banned-call", "'savefig' may only be called directly")] + assert ("banned-call", "'savefig' may only be called directly") in { + (f.rule, f.message) for f in validate_data_code("import pandas as pd\ns = savefig\ndf = pd.DataFrame()\n") + } + + # `np.save` shares its name with the `save` of plotnine and altair: the save rule resolves the receiver, and a # module outside the plotting library is never the plot's save (finding of the 1b review, 2026-10-25). @pytest.mark.parametrize("library", CODE_LIBRARIES) @@ -2023,6 +2708,139 @@ def test_numpy_save_is_a_banned_call_in_every_profile(library: str, body: str) - assert "banned-call" in rules_of(library_plot(library, body), library) +# THEME names every save's file, and libcairo writes pygal's PNG where the render guard never looks, so in every +# profile the name keeps the theme block's value (the security review of 2026-10-26): a body that binds THEME, the +# 1-based line of the binding in it, and the other rules the body breaks. +THEME_BINDINGS = [ + pytest.param('THEME = "../x"\n', 1, set(), id="a second assignment"), + pytest.param('THEME = os.getenv("ANYPLOT_THEME", "dark")\n', 1, set(), id="the theme block twice"), + pytest.param('THEME += "x"\n', 1, set(), id="augmented"), + pytest.param('THEME: str = "x"\n', 1, set(), id="annotated"), + pytest.param('THEME, n = "x", 1\n', 1, set(), id="unpacked"), + pytest.param('for THEME in ["x"]:\n pass\n', 1, set(), id="for"), + pytest.param("import warnings\nwith warnings.catch_warnings() as THEME:\n pass\n", 2, set(), id="with"), + pytest.param("try:\n pass\nexcept ValueError as THEME:\n pass\n", 3, set(), id="except"), + pytest.param('match "x":\n case THEME:\n pass\n', 2, set(), id="match capture"), + pytest.param('match ["x"]:\n case [*THEME]:\n pass\n', 2, set(), id="match star"), + pytest.param("match {}:\n case {**THEME}:\n pass\n", 2, set(), id="match rest"), + pytest.param('n = (THEME := "x")\n', 1, set(), id="walrus"), + pytest.param('names = [THEME for THEME in ["x"]]\n', 1, set(), id="comprehension"), + pytest.param("from textwrap import dedent as THEME\n", 1, set(), id="from import"), + pytest.param("import textwrap as THEME\n", 1, {"module-value"}, id="import"), + pytest.param('def THEME():\n return "x"\n', 1, set(), id="def"), + pytest.param('def f(THEME="x"):\n return THEME\n', 1, set(), id="parameter"), + pytest.param("f = lambda THEME: THEME\n", 1, set(), id="lambda parameter"), + pytest.param('def f():\n THEME = "x"\n return THEME\n', 2, set(), id="a local"), + pytest.param("def f():\n global THEME\n", 2, {"banned-statement"}, id="global"), + pytest.param( + 'def f():\n THEME = "x"\n\n def g():\n nonlocal THEME\n', 5, {"banned-statement"}, id="nonlocal" + ), + pytest.param("del THEME\n", 1, set(), id="del"), + pytest.param("type THEME = str\n", 1, set(), id="type alias"), + pytest.param("def f[THEME]():\n pass\n", 1, set(), id="type parameter"), +] +THEME_READS = [ + pytest.param("upper = THEME.upper()\n", id="a read"), + pytest.param("def f(theme=THEME):\n return theme\n", id="a default"), + pytest.param('d = dict(THEME="x")\n', id="a keyword"), + pytest.param('labels = {"THEME": THEME}\n', id="a dict key"), + pytest.param('THEME_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"\n', id="another name"), +] +# With `import os`, the name is bound by nothing else: each of these hands the theme block another object's `getenv`. +OS_BINDINGS = [ + pytest.param( + 'import functools\n\n\ndef g():\n pass\n\n\ng.getenv = lambda *a: "../x"\n\n\n' + '@functools.wraps(g, assigned=("getenv",), updated=())\ndef os():\n pass\n', + 12, + id="def under wraps", + ), + pytest.param("from textwrap import dedent as os\n", 1, id="from import"), + pytest.param("import textwrap as os\n", 1, id="import"), + pytest.param("try:\n pass\nexcept ValueError as os:\n pass\n", 3, id="except"), + pytest.param("def f(os):\n return 1\n", 1, id="parameter"), + pytest.param('match "x":\n case os:\n pass\n', 2, id="match capture"), +] + + +def body_line(code: str, body: str, line: int) -> int: + """The line of `code` that is line `line` of `body`, which `code` holds once.""" + assert code.count(body) == 1, body + return code.count("\n", 0, code.index(body)) + line + + +@pytest.mark.parametrize("library", CODE_LIBRARIES) +@pytest.mark.parametrize(("body", "line", "others"), THEME_BINDINGS) +def test_a_binding_of_theme_is_refused_in_every_profile(library: str, body: str, line: int, others: set[str]) -> None: + code = library_plot(library, body) + findings = validate_security(code, library=library) + assert {f.rule for f in findings} == {"savefig-target"} | others + assert ("savefig-target", THEME_BINDING_MESSAGE, body_line(code, body, line)) in { + (f.rule, f.message, f.line) for f in findings + } + + +@pytest.mark.parametrize("library", CODE_LIBRARIES) +@pytest.mark.parametrize("body", THEME_READS) +def test_a_read_of_theme_passes_in_every_profile(library: str, body: str) -> None: + assert rules_of(library_plot(library, body), library) == set() + + +@pytest.mark.parametrize("library", CODE_LIBRARIES) +@pytest.mark.parametrize( + "block", + [ + pytest.param('THEME = os.environ.get("ANYPLOT_THEME", "light")', id="os.environ.get"), + pytest.param('THEME = os.getenv("ANYPLOT_THEME", "light").lower()', id="a call on the lookup"), + pytest.param('THEME = T = os.getenv("ANYPLOT_THEME", "light")', id="two targets"), + ], +) +def test_a_theme_block_of_another_shape_is_a_finding(library: str, block: str) -> None: + """The theme block is the shape `regions.is_theme_assignment` accepts, which readiness looks for too.""" + code = swapped(library_plot(library), THEME_LINE, block) + findings = validate_security(code, library=library) + assert [(f.rule, f.message, f.line) for f in findings] == [ + ("savefig-target", THEME_BINDING_MESSAGE, code.splitlines().index(block) + 1) + ] + + +@pytest.mark.parametrize("library", CODE_LIBRARIES) +@pytest.mark.parametrize( + "block", + [ + pytest.param('THEME: str = os.getenv("ANYPLOT_THEME", "light")', id="annotated"), + pytest.param('THEME = os.getenv("ANYPLOT_THEME")', id="no default"), + pytest.param('THEME = os.getenv("ANYPLOT_THEME", default="light")', id="default by keyword"), + ], +) +def test_a_theme_block_regions_accepts_passes(library: str, block: str) -> None: + assert rules_of(swapped(library_plot(library), THEME_LINE, block), library) == set() + + +@pytest.mark.parametrize("library", CODE_LIBRARIES) +def test_the_theme_block_reads_the_imported_os(library: str) -> None: + """Without `import os`, `os` is a name the file binds, and its `getenv` returns what the file says.""" + code = swapped(library_plot(library, "def os():\n pass\n"), "import os\n", "") + findings = validate_security(code, library=library) + assert [(f.rule, f.message, f.line) for f in findings] == [ + ("savefig-target", THEME_BINDING_MESSAGE, code.splitlines().index(THEME_LINE) + 1) + ] + + +@pytest.mark.parametrize("library", CODE_LIBRARIES) +@pytest.mark.parametrize(("body", "line"), OS_BINDINGS) +def test_a_binding_of_the_imported_os_is_refused(library: str, body: str, line: int) -> None: + code = library_plot(library, body) + findings = validate_security(code, library=library) + assert [(f.rule, f.message, f.line) for f in findings] == [ + ("os-use", "'os' may only be bound by import os", body_line(code, body, line)) + ] + + +def test_data_code_may_bind_theme() -> None: + """A data snippet saves nothing, so the THEME pin, a `savefig-target` rule, is no rule of the DATA profile.""" + assert validate_data_code('import pandas as pd\nTHEME = "x"\nTHEME = "y"\ndf = pd.DataFrame()\n') == [] + + # --- the catalogue corpus ---------------------------------------------------------------------- diff --git a/tests/unit/agents/evals/test_cases.py b/tests/unit/agents/evals/test_cases.py index 4e1bf9c379b..6704f0ebb03 100644 --- a/tests/unit/agents/evals/test_cases.py +++ b/tests/unit/agents/evals/test_cases.py @@ -30,7 +30,7 @@ def test_every_committed_case_loads() -> None: cases = load_cases(promoted_dir=Path("/nonexistent")) stress = [case for case in cases if "stress" in case.tags] - assert len(cases) - len(stress) == 247 # 240 generated plus the seven hand-written cases + assert len(cases) - len(stress) == 367 # 360 generated plus the seven hand-written cases assert len(stress) == 23 and all(case.case_id.startswith("stress-") for case in stress) assert [case.case_id for case in cases] == sorted(case.case_id for case in cases) assert {case.origin for case in cases} == {"fixtures"} @@ -72,15 +72,15 @@ def test_selection_by_smoke_full_and_globs() -> None: cases = load_cases(promoted_dir=Path("/nonexistent")) smoke = select_cases(cases, "smoke") - assert len(smoke) == 14 and all("smoke" in case.tags for case in smoke) + assert len(smoke) == 16 and all("smoke" in case.tags for case in smoke) full = select_cases(cases, "full") - assert len(full) == 247 and full == [case for case in cases if "stress" not in case.tags] + assert len(full) == 367 and full == [case for case in cases if "stress" not in case.tags] stress = select_cases(cases, "stress") assert len(stress) == 23 and all("stress" in case.tags for case in stress) assert len(select_cases(cases, "stress-compute-*")) == 3 assert len(select_cases(cases, "*-seaborn-n5000")) == 10 picked = select_cases(cases, "pie-basic-*, scatter-basic-matplotlib") - assert len(picked) == 25 and picked[-1].case_id == "scatter-basic-matplotlib" # 24 pie-basic cases, four libraries + assert len(picked) == 37 and picked[-1].case_id == "scatter-basic-matplotlib" # 36 pie-basic cases, six libraries assert select_cases(cases, "nothing-*") == [] diff --git a/tests/unit/agents/evals/test_make_fixtures.py b/tests/unit/agents/evals/test_make_fixtures.py index 09907af4a44..863520198aa 100644 --- a/tests/unit/agents/evals/test_make_fixtures.py +++ b/tests/unit/agents/evals/test_make_fixtures.py @@ -29,6 +29,12 @@ CASE_KEYS = {"spec_id", "library", "locale", "perturbation", "bindings", "expected", "tags", "source"} FAMILIES = {"scatter", "line", "bar", "histogram", "box", "heatmap", "area", "violin", "pie", "time-series"} +BLOCKED = frozenset({("histogram-basic", "pygal"), ("line-timeseries", "pygal")}) +"""The `(spec, library)` files the readiness scan blocks for SECURITY findings; the generator's docstring says why. + +Their cases are written all the same. A change that makes one of them eligible fails the readiness test until +the pair leaves this set and its cell in the docstring's table changes with it. +""" @pytest.fixture(scope="module") @@ -58,11 +64,11 @@ def test_check_mode_reports_a_stale_file(tmp_path, capsys: pytest.CaptureFixture assert "stale: pie-basic-seaborn-n12/data.csv" in capsys.readouterr().err -def test_ten_specs_of_ten_families_times_four_libraries_times_six_perturbations(files: dict[str, str]) -> None: +def test_ten_specs_of_ten_families_times_six_libraries_times_six_perturbations(files: dict[str, str]) -> None: assert len(SPECS) == 10 and {spec.family for spec in SPECS} == FAMILIES - assert LIBRARIES == ("matplotlib", "seaborn", "plotnine", "altair") + assert LIBRARIES == ("matplotlib", "seaborn", "plotnine", "altair", "pygal", "letsplot") ids = {name.split("/")[0] for name in files} - assert len(ids) == 240 + assert len(ids) == 360 assert ids == { case_id(spec.spec_id, library, perturbation) for spec in SPECS @@ -104,7 +110,7 @@ def test_smoke_covers_every_spec_perturbation_and_library(files: dict[str, str]) smoke = [case for case in smoke if SMOKE_TAG in case["tags"]] cycle = [case for case in smoke if case["library"] in SMOKE_CYCLE] - assert len(smoke) == len(SPECS) + len(LIBRARY_SMOKE) == 12 + assert len(smoke) == len(SPECS) + len(LIBRARY_SMOKE) == 14 assert set(SMOKE_CYCLE) | set(LIBRARY_SMOKE) == set(LIBRARIES) and not set(SMOKE_CYCLE) & set(LIBRARY_SMOKE) assert len(cycle) == len(SPECS) assert {case["spec_id"] for case in cycle} == {spec.spec_id for spec in SPECS} @@ -115,7 +121,7 @@ def test_smoke_covers_every_spec_perturbation_and_library(files: dict[str, str]) def test_a_new_library_never_moves_the_smoke_cycle(files: dict[str, str]) -> None: - """The ten cycle cases are the ones of `--cases smoke` before step 1b; plotnine and altair add one each.""" + """The ten cycle cases are the ones of `--cases smoke` before step 1b; every later library adds one.""" smoke = sorted( name.split("/")[0] for name, text in files.items() @@ -131,20 +137,29 @@ def test_a_new_library_never_moves_the_smoke_cycle(files: dict[str, str]) -> Non "line-timeseries-seaborn-n5000", "pie-basic-matplotlib-n12", "scatter-basic-altair-renamed", + "scatter-basic-letsplot-renamed", "scatter-basic-matplotlib-renamed", "scatter-basic-plotnine-renamed", + "scatter-basic-pygal-renamed", "violin-basic-seaborn-x10", ] @pytest.mark.parametrize("spec", SPECS, ids=lambda spec: spec.spec_id) def test_every_case_parses_binds_and_is_eligible(spec: make_fixtures.SpecFixture, files: dict[str, str]) -> None: - """The cases test the adaptation, never the parser: no warning, complete bindings, an eligible pair.""" + """The cases test the adaptation, never the parser: no warning, complete bindings, an eligible pair. + + The `BLOCKED` pairs are the exception: blocked, and for SECURITY findings, not for a disabled library. + """ settings = AgentSettings(libraries=list(LIBRARIES)) # the default enables matplotlib and seaborn only for library in LIBRARIES: snapshot = snapshot_from_repo(spec.spec_id, library) eligibility, _ = assess(snapshot, library, settings) - assert eligibility.status in ("clean", "coupled"), (library, eligibility.reasons) + if (spec.spec_id, library) in BLOCKED: + assert eligibility.status == "blocked", (library, eligibility.reasons) + assert any(reason.startswith("security: ") for reason in eligibility.reasons), eligibility.reasons + else: + assert eligibility.status in ("clean", "coupled"), (library, eligibility.reasons) roles = parse_roles(snapshot.data_roles) for perturbation in PERTURBATIONS: directory = case_id(spec.spec_id, library, perturbation) diff --git a/tests/unit/agents/renderer/test_commands.py b/tests/unit/agents/renderer/test_commands.py index d4e0073ec1e..8039bc2463a 100644 --- a/tests/unit/agents/renderer/test_commands.py +++ b/tests/unit/agents/renderer/test_commands.py @@ -1,5 +1,8 @@ """The renderer's command table (agents/renderer/commands.py): one fixed command per language, pinned to the catalogue.""" +import re +from pathlib import Path + import pytest from agents.renderer import commands @@ -37,6 +40,42 @@ def test_python_runs_in_process_and_keeps_every_limit(self) -> None: assert command_for("python", "plotnine").rlimit_as altair = command_for("python", "altair") # vl-convert's V8 reserves address space like Chromium's assert altair.in_process and not altair.rlimit_as and altair.rlimit_nproc + for library in ("matplotlib", "seaborn", "plotnine", "altair", "pygal"): + row = command_for("python", library) + assert row.env == {} and row.rlimit_as_mb is None and row.render_p95_s == 15.0, library + + def test_pygal_keeps_the_python_defaults(self) -> None: + """cairo reserves no address space beyond its resident set; the heaviest catalogue render peaks at 682 MB.""" + assert command_for("python", "pygal") == command_for("python", "matplotlib") + + def test_lets_plot_has_its_own_address_space_limit_and_environment(self) -> None: + """The native bridge needs more than the configured 1 GiB, with Arrow's system allocator (spike 2026-10-26).""" + row = command_for("python", "letsplot") + assert row.in_process and row.argv == () and row.code_file == "plot.py" and row.toolchain == () + assert row.rlimit_as and row.rlimit_as_mb == 2048 and row.rlimit_nproc and row.render_p95_s == 15.0 + assert dict(row.env) == {"ARROW_DEFAULT_MEMORY_POOL": "system", "FONTCONFIG_FILE": "/opt/anyplot/fonts.conf"} + assert commands.FONTCONFIG_FILE == "/opt/anyplot/fonts.conf" + + @pytest.mark.parametrize( + ("rlimit_as", "rlimit_as_mb"), + [ + pytest.param(False, 2048, id="with RLIMIT_AS off"), + pytest.param(True, 0, id="zero"), + pytest.param(True, -1, id="negative"), + ], + ) + def test_a_row_limit_needs_rlimit_as_on_and_a_positive_size(self, rlimit_as: bool, rlimit_as_mb: int) -> None: + with pytest.raises(ValueError, match="own RLIMIT_AS"): + RuntimeCommand("python", "plot.py", (), rlimit_as=rlimit_as, rlimit_as_mb=rlimit_as_mb) + + def test_the_image_carries_the_fontconfig_file_the_row_names(self) -> None: + """`agents/renderer/fonts.conf` is copied to `FONTCONFIG_FILE` by the Dockerfile: one font directory, no + cache directory, so the bridge's fontconfig never writes.""" + renderer = Path(commands.__file__).parent + conf = (renderer / "fonts.conf").read_text(encoding="utf-8") + assert re.findall(r"([^<]*)", conf) == ["/usr/share/fonts"] and " None: js = command_for("javascript", "chartjs") diff --git a/tests/unit/agents/renderer/test_delegates.py b/tests/unit/agents/renderer/test_delegates.py new file mode 100644 index 00000000000..b1a1307dd48 --- /dev/null +++ b/tests/unit/agents/renderer/test_delegates.py @@ -0,0 +1,53 @@ +"""The delegate check of the renderer image: no program lets-plot's native ImageMagick can start is on the sandbox PATH.""" + +from pathlib import Path + +import pytest + +from agents.renderer import delegates +from agents.renderer.executor import SANDBOX_PATH + + +REPO_ROOT = Path(__file__).resolve().parents[4] + + +def test_a_clean_path_passes(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + monkeypatch.setattr(delegates.shutil, "which", lambda name, path=None: None) + + assert delegates.main() == 0 + assert f"none of {len(delegates.DELEGATE_PROGRAMS)} delegate programs on {SANDBOX_PATH}" in capsys.readouterr().out + + +def test_a_delegate_on_the_sandbox_path_fails_and_is_named( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + looked_up: list[str | None] = [] + + def which(name: str, path: str | None = None) -> str | None: + looked_up.append(path) + return "/usr/bin/gs" if name == "gs" else None + + monkeypatch.setattr(delegates.shutil, "which", which) + + assert delegates.main() == 1 + assert "gs (/usr/bin/gs)" in capsys.readouterr().out + assert set(looked_up) == {SANDBOX_PATH} # the sandbox's PATH, never the process's own + + +def test_the_list_covers_the_review_s_programs() -> None: + reviewed = {"gs", "curl", "soffice", "ffmpeg", "inkscape", "rsvg-convert", "fc-list", "magick", "convert", "node"} + + assert reviewed <= set(delegates.DELEGATE_PROGRAMS) + assert len(set(delegates.DELEGATE_PROGRAMS)) == len(delegates.DELEGATE_PROGRAMS) + + +def test_every_build_of_the_image_runs_the_check() -> None: + """The Dockerfile runs it, so the Cloud Build deploy enforces it as well as the CI job, which runs it again.""" + dockerfile = (REPO_ROOT / "agents" / "renderer" / "Dockerfile").read_text(encoding="utf-8") + workflow = (REPO_ROOT / ".github" / "workflows" / "ci-image.yml").read_text(encoding="utf-8") + + assert "RUN .venv/bin/python -m agents.renderer.delegates" in dockerfile + assert dockerfile.index("COPY agents/renderer/ ./agents/renderer/") < dockerfile.index( + "-m agents.renderer.delegates" + ) + assert "/app/.venv/bin/python -m agents.renderer.delegates" in workflow diff --git a/tests/unit/agents/renderer/test_language_rows.py b/tests/unit/agents/renderer/test_language_rows.py index 18f3fcf99b2..2f156d5ff21 100644 --- a/tests/unit/agents/renderer/test_language_rows.py +++ b/tests/unit/agents/renderer/test_language_rows.py @@ -51,6 +51,80 @@ def test_a_row_switches_the_limits_it_cannot_live_under(self, config: ExecutorCo data = sandbox_env(limited, None, 20) assert data["ANYPLOT_RLIMIT_AS_MB"] == "1024" and "ANYPLOT_THEME" not in data + def test_an_in_process_row_brings_its_environment_and_its_own_limit(self, config: ExecutorConfig) -> None: + """lets-plot's row: the matplotlib settings, then Arrow's system allocator and the fontconfig file, and its + 2048 MiB in place of the configured `RLIMIT_AS` (spike of 2026-10-26).""" + limited = replace(config, rlimit_as_mb=1024, rlimit_nproc=64) + letsplot = sandbox_env(limited, "light", 60, command=command_for("python", "letsplot")) + + assert letsplot["ANYPLOT_RLIMIT_AS_MB"] == "2048" and letsplot["ANYPLOT_RLIMIT_NPROC"] == "64" + assert letsplot["ARROW_DEFAULT_MEMORY_POOL"] == "system" + assert letsplot["FONTCONFIG_FILE"] == "/opt/anyplot/fonts.conf" + assert letsplot["MPLBACKEND"] == "Agg" and letsplot["ANYPLOT_THEME"] == "light" + assert letsplot["PATH"] == SANDBOX_PATH and letsplot["HOME"] == "/tmp" + # pygal keeps the Python defaults: the same environment as matplotlib's, the configured limit included. + pygal = sandbox_env(limited, "light", 60, command=command_for("python", "pygal")) + assert pygal == sandbox_env(limited, "light", 60, command=command_for("python", "matplotlib")) + assert pygal["ANYPLOT_RLIMIT_AS_MB"] == "1024" and "ARROW_DEFAULT_MEMORY_POOL" not in pygal + + def test_an_in_process_row_env_comes_after_the_matplotlib_keys_and_before_the_fixed_ones( + self, config: ExecutorConfig + ) -> None: + """The merge order `sandbox_env` states: a row's own value replaces a matplotlib setting, and never `PATH`, + `HOME`, the bytecode switch or a limit variable.""" + limited = replace(config, rlimit_as_mb=1024) + row = replace( + command_for("python", "pygal"), + env={ + "MPLBACKEND": "svg", + "PATH": "/opt/row/bin", + "HOME": "/opt/row", + "PYTHONDONTWRITEBYTECODE": "0", + "ANYPLOT_RLIMIT_AS_MB": "0", + "ANYPLOT_RLIMIT_CPU_S": "0", + }, + ) + values = sandbox_env(limited, "light", 60, command=row) + + assert values["MPLBACKEND"] == "svg" and values["MPLCONFIGDIR"] == config.mplconfig + assert values["PATH"] == SANDBOX_PATH and values["HOME"] == "/tmp" + assert values["PYTHONDONTWRITEBYTECODE"] == "1" + assert values["ANYPLOT_RLIMIT_AS_MB"] == "1024" and values["ANYPLOT_RLIMIT_CPU_S"] == "60" + + def test_a_row_limit_never_switches_a_configured_off_limit_on(self, config: ExecutorConfig) -> None: + """`RENDERER_RLIMIT_AS_MB=0` turns address-space limits off for every row, lets-plot's own value included.""" + unlimited = sandbox_env(config, "dark", 60, command=command_for("python", "letsplot")) + + assert config.rlimit_as_mb == 0 and "ANYPLOT_RLIMIT_AS_MB" not in unlimited + assert unlimited["ARROW_DEFAULT_MEMORY_POOL"] == "system" + + def test_data_runs_and_conversions_are_unchanged_by_the_python_rows(self, config: ExecutorConfig) -> None: + limited = replace(config, rlimit_as_mb=1024, rlimit_nproc=64) + + data = sandbox_env(limited, None, 20) + assert data["ANYPLOT_RLIMIT_AS_MB"] == "1024" and "ARROW_DEFAULT_MEMORY_POOL" not in data + assert "FONTCONFIG_FILE" not in data and "MPLBACKEND" not in data + convert = sandbox_env(limited, None, 20, convert=True) + assert convert["ANYPLOT_RLIMIT_AS_MB"] == "1024" and convert["ARROW_DEFAULT_MEMORY_POOL"] == "system" + assert "FONTCONFIG_FILE" not in convert + + def test_the_lets_plot_row_runs_the_plot_mode_command(self, config: ExecutorConfig, tmp_path: Path) -> None: + limited = replace(config, rlimit_as_mb=1024) + argv = build_argv(limited, "r-a-light", tmp_path, "light", 60, command=command_for("python", "letsplot")) + + assert argv[argv.index("--") :] == [ + "--", + config.python, + "-I", + "-X", + "disable-remote-debug", + config.harness, + "plot.py", + ] + values = envs(argv) + assert values["ANYPLOT_RLIMIT_AS_MB"] == "2048" and values["ARROW_DEFAULT_MEMORY_POOL"] == "system" + assert EXEC_FLAG not in argv + def test_the_muix_row_writes_a_tsx_file(self, config: ExecutorConfig, tmp_path: Path) -> None: argv = build_argv(config, "r-a-light", tmp_path, "light", 60, command=command_for("javascript", "muix")) diff --git a/tests/unit/agents/runtime/test_harness_guard.py b/tests/unit/agents/runtime/test_harness_guard.py index cc390fddd0b..af966c55bd6 100644 --- a/tests/unit/agents/runtime/test_harness_guard.py +++ b/tests/unit/agents/runtime/test_harness_guard.py @@ -7,6 +7,7 @@ behind the validator, so these plots and snippets are written as if the validator had missed them. """ +import importlib.util import json import struct import subprocess @@ -52,6 +53,15 @@ def render_file(tmp_path: Path, code: str) -> tuple[int, str]: return done.returncode, done.stderr +def import_or_skip(module: str) -> None: + """Skip the test unless `module` imports here: `pytest.importorskip` skips only an ImportError, but cairosvg's + import raises OSError when cairocffi finds no libcairo on the machine.""" + try: + importlib.import_module(module) + except (ImportError, OSError) as exc: + pytest.skip(f"{module} does not import here: {type(exc).__name__}: {exc}") + + def run_data(tmp_path: Path, code: str) -> tuple[int, str | None, str]: """Exit code, `out.csv` (None when missing) and stderr of one data-mode run in `tmp_path`.""" (tmp_path / "snippet.py").write_text(code, encoding="utf-8") @@ -70,6 +80,26 @@ def run_data(tmp_path: Path, code: str) -> tuple[int, str | None, str]: # Each writes the marker file (or tries to) through a route the guard must refuse; the payload is text the # route compiles or runs, so a refusal leaves no marker behind. WRITE_MARKER = f"open('{MARKER}', 'w').write('x')" +NO_CSSSELECT2 = pytest.mark.skipif( + importlib.util.find_spec("cssselect2") is None, reason="needs cssselect2 (lib-pygal)" +) + + +def forged_selector(payload: str) -> str: + """A plot body that hands cssselect2's compiler Python objects in place of tokens: a `dimension` whose `int_value` + is `payload` lands unquoted in the `eval` text `CompiledSelector.__init__` builds (`CODE_BUILDERS`), and the + lambda runs it when it tests an element (the pygal spike of 2026-10-26; without a guard it writes the marker).""" + return ( + f"import cssselect2.compiler as compiler\nPAYLOAD = {payload!r}\n" + "class Token:\n def __init__(self, **fields):\n self.__dict__.update(fields)\n" + " def __eq__(self, other):\n return other == self.__dict__.get('eq')\n" + "dimension = Token(type='dimension', is_integer=True, lower_unit='n', int_value=PAYLOAD)\n" + "nth = Token(type='function', lower_name='nth-child', arguments=[dimension], value=None)\n" + "[selector] = compiler.compile_selector_list([Token(type='literal', value=':', eq=':'), nth])\n" + "class Element:\n index = 0\nselector.test(Element())\n" + ) + + PLOT_ROUTES = [ pytest.param(f'np.testing.measure("{WRITE_MARKER}")\n', id="numpy.testing.measure (the reported case)"), pytest.param(f'np.testing.runstring("{WRITE_MARKER}", {{}})\n', id="numpy.testing.runstring"), @@ -158,6 +188,22 @@ def run_data(tmp_path: Path, code: str) -> tuple[int, str | None, str]: f"open('{MARKER}', 'w').write('x')\n", id="gc hands out the guard", ), + # cssselect2's compiler is a `CODE_BUILDERS` entry (pygal's PNG export): an `__init__` anywhere else is not it, + # and code its `eval` built runs under the guard like the plot's own. + pytest.param( + f'class Builder:\n def __init__(self):\n exec("{WRITE_MARKER}")\nBuilder()\n', + id="exec from an __init__ that is not cssselect2's", + ), + pytest.param( + forged_selector(f"1) and {WRITE_MARKER} or (1"), + id="a forged cssselect2 token writes from the built lambda", + marks=NO_CSSSELECT2, + ), + pytest.param( + forged_selector(f'1) and exec("{WRITE_MARKER}") or (1'), + id="a forged cssselect2 token execs from the built lambda", + marks=NO_CSSSELECT2, + ), ] # Fable review of #12181: a trace, profile or signal callback gets the real frame and walks `f_back` (no audit event) # to `run_plot`'s or `run_data_snippet`'s locals, where the guard sits. Each payload disarms whatever guard it finds, @@ -353,6 +399,83 @@ def test_altair_renders_its_png_and_html(self, tmp_path: Path) -> None: assert (tmp_path / "plot-light.png").read_bytes()[:8] == b"\x89PNG\r\n\x1a\n" assert " None: + """pygal's PNG goes through cairosvg: `warm_cairo` loads libcairo before the guard, and cssselect2 compiles the + chart's stylesheet into lambdas through `eval` (`CODE_BUILDERS`). No matplotlib figure, so no probe.""" + pytest.importorskip("pygal") + import_or_skip("cairosvg") + code = ( + f"import os\n\n{imports}import pygal\n\nTHEME = os.environ['ANYPLOT_THEME']\n" + f"chart = pygal.Bar(width={size[0]}, height={size[1]}, title='t')\nchart.add('y', [1, 3, 2])\n" + f"{save}chart.render_to_file(f'plot-{{THEME}}.html')\n" + ) + exit_code, stderr = render_file(tmp_path, code) + assert exit_code == 0, stderr[-3000:] + assert "blocked by the" not in stderr + png = (tmp_path / "plot-light.png").read_bytes() + assert png[:8] == b"\x89PNG\r\n\x1a\n" and struct.unpack(">II", png[16:24]) == (400, 300) + assert " None: + """lets-plot rasterises in its native bridge, loaded at import: the guard needs no entry for it, and the two + files `path="."` names are the only writes. No matplotlib figure, so no probe.""" + pytest.importorskip("lets_plot") + code = self.LETS_PLOT + ( + "ggsave(plot, f'plot-{THEME}.png', path='.', scale=2)\nggsave(plot, f'plot-{THEME}.html', path='.')\n" + ) + exit_code, stderr = render_file(tmp_path, code) + assert exit_code == 0, stderr[-3000:] + assert "blocked by the" not in stderr + png = (tmp_path / "plot-light.png").read_bytes() + assert png[:8] == b"\x89PNG\r\n\x1a\n" and struct.unpack(">II", png[16:24]) == (400, 300) + assert "data-lets-plot-script" in (tmp_path / "plot-light.html").read_text(encoding="utf-8") + assert not (tmp_path / "probe-light.json").exists() + + def test_a_lets_plot_save_without_path_is_refused(self, tmp_path: Path) -> None: + """Without `path=`, `ggsave` creates `./lets-plot-images` (`os.mkdir`), which the guard refuses; the + normaliser adds `path="."` to every save.""" + pytest.importorskip("lets_plot") + exit_code, stderr = render_file(tmp_path, self.LETS_PLOT + "ggsave(plot, f'plot-{THEME}.png', scale=2)\n") + assert exit_code != 0 and "blocked by the render sandbox: os.mkdir" in stderr, stderr[-2000:] + assert not (tmp_path / "lets-plot-images").exists() and not (tmp_path / "plot-light.png").exists() + + def test_a_cssselect2_selector_compiles_and_runs_under_the_guard(self, tmp_path: Path) -> None: + """`CompiledSelector.__init__` builds each selector's test with `eval` (`CODE_BUILDERS`), and the lambda it + built runs under the guard like any plot code.""" + pytest.importorskip("cssselect2") + body = ( + "import xml.etree.ElementTree as ET\n\nimport cssselect2\n\n" + "[selector] = cssselect2.compile_selector_list('rect.a')\n" + "root = cssselect2.ElementWrapper.from_xml_root(ET.fromstring(''))\n" + "assert [element.local_name for element in root.iter_subtree() if selector.test(element)] == ['rect']\n" + ) + exit_code, stderr = render(tmp_path, body) + assert exit_code == 0, stderr[-3000:] + assert "blocked by the" not in stderr + def test_a_syntax_error_is_reported_before_the_guard(self, tmp_path: Path) -> None: exit_code, stderr = render(tmp_path, "x = (\n") assert exit_code != 0 and "SyntaxError" in stderr and 'plot.py", line' in stderr @@ -371,6 +494,18 @@ def test_a_caught_frame_refusal_still_renders(self, tmp_path: Path) -> None: assert (tmp_path / "plot-light.png").exists() +@pytest.mark.parametrize("error", [ModuleNotFoundError("No module named 'cairosvg'"), OSError("no library called")]) +def test_a_library_that_does_not_import_skips_its_case(monkeypatch: pytest.MonkeyPatch, error: Exception) -> None: + """A missing module and a missing libcairo under an installed cairosvg (cairocffi's OSError) both skip.""" + + def fail(name: str) -> None: + raise error + + monkeypatch.setattr(importlib, "import_module", fail) + with pytest.raises(pytest.skip.Exception, match=type(error).__name__): + import_or_skip("cairosvg") + + REPO_ROOT = Path(__file__).resolve().parents[4] CATALOGUE_UNDER_GUARD = [ pytest.param("ice-basic", "matplotlib", id="ice-basic/matplotlib: scikit-learn GradientBoostingRegressor"), @@ -379,23 +514,37 @@ def test_a_caught_frame_refusal_still_renders(self, tmp_path: Path) -> None: pytest.param("silhouette-basic", "seaborn", id="silhouette-basic/seaborn: KMeans"), pytest.param("scatter-basic", "plotnine", id="scatter-basic/plotnine: aes through Environment.capture"), pytest.param("scatter-basic", "altair", id="scatter-basic/altair: vl-convert, PIL padding and the HTML"), + pytest.param("scatter-basic", "pygal", id="scatter-basic/pygal: cairosvg, cssselect2 and the HTML"), + pytest.param("line-basic", "pygal", id="line-basic/pygal: a 4800x2700 chart saved through svg2png at 3200x1800"), + pytest.param("scatter-basic", "letsplot", id="scatter-basic/letsplot: the native bridge and the HTML"), ] -LIBRARY_MODULES = {"seaborn": ("seaborn",), "plotnine": ("plotnine",), "altair": ("altair", "vl_convert")} +LIBRARY_MODULES = { + "seaborn": ("seaborn",), + "plotnine": ("plotnine",), + "altair": ("altair", "vl_convert"), + "pygal": ("pygal", "cairosvg"), + "letsplot": ("lets_plot",), +} """The modules a library's catalogue case needs beyond matplotlib's stack; CI's test environment installs none.""" +WITHOUT_PROBE = frozenset({"altair", "pygal", "letsplot"}) +"""The libraries that draw no matplotlib figure, so their runs write no probe but an HTML twin.""" +ON_CANVAS = frozenset({"pygal", "letsplot"}) +"""The libraries whose cases the normaliser's canvas rewrite holds to 3200x1800 (the 1c decisions).""" @pytest.mark.parametrize(("spec_id", "library"), CATALOGUE_UNDER_GUARD) def test_catalogue_files_render_under_the_guard(tmp_path: Path, spec_id: str, library: str) -> None: """Real catalogue files, normalised as a session opening does, render under the plot guard: a guard change that - breaks scikit-learn, statsmodels, mathtext, plotnine's frame read or altair's vl-convert fails here. The seaborn, - plotnine and altair cases need their libraries, which CI's test environment does not install; the renderer-image - job of `ci-image.yml` renders all three through the harness in the image. altair draws no matplotlib figure, so its - run writes no probe: the PNG and its HTML twin are what the case checks.""" + breaks scikit-learn, statsmodels, mathtext, plotnine's frame read, altair's vl-convert, pygal's cairosvg or + lets-plot's bridge fails here. The seaborn, plotnine, altair, pygal and lets-plot cases need their libraries, which + CI's test environment does not install; the renderer-image job of `ci-image.yml` renders all five through the + harness in the image. altair, pygal and lets-plot draw no matplotlib figure, so their runs write no probe: the PNG + and its HTML twin are what the case checks.""" from agents.anyplot.code.normalise import normalise from core.utils import strip_noqa_comments for module in LIBRARY_MODULES.get(library, ()): - pytest.importorskip(module) + import_or_skip(module) source = REPO_ROOT / "plots" / spec_id / "implementations" / "python" / f"{library}.py" if not source.exists(): pytest.skip(f"{spec_id}/{library} is absent") @@ -419,10 +568,12 @@ def test_catalogue_files_render_under_the_guard(tmp_path: Path, spec_id: str, li assert done.returncode == 0, done.stderr[-3000:] assert "blocked by the" not in done.stderr assert (tmp_path / "plot-light.png").exists() - if library == "altair": + if library in WITHOUT_PROBE: assert (tmp_path / "plot-light.html").exists() else: assert (tmp_path / "probe-light.json").exists() + if library in ON_CANVAS: + assert struct.unpack(">II", (tmp_path / "plot-light.png").read_bytes()[16:24]) == (3200, 1800) class TestPlotGuardUnit: @@ -480,6 +631,21 @@ def test_frame_readers_match_their_whole_chain(self) -> None: assert harness.reads_frames(type("Frame", (), {"f_code": walker, "f_back": consumed})) assert not harness.reads_frames(None) + def test_cssselect2s_compiler_builds_code_and_nothing_else_of_it_does(self) -> None: + """The entry is the compiler's `CompiledSelector.__init__` alone: another function of the file, an `__init__` + elsewhere and a directory that merely ends in the package name do not match.""" + + def frame(path: str, name: str) -> object: + code = type("Code", (), {"co_filename": path, "co_name": name}) + return type("Frame", (), {"f_code": code, "f_back": None}) + + site = "/app/.venv/lib/python3.14/site-packages" + assert ("cssselect2/compiler.py", "__init__") in harness.CODE_BUILDERS + assert harness.builds_code(frame(f"{site}/cssselect2/compiler.py", "__init__")) + assert not harness.builds_code(frame(f"{site}/cssselect2/compiler.py", "compile_selector_list")) + assert not harness.builds_code(frame("plot.py", "__init__")) + assert not harness.builds_code(frame(f"{site}/not_cssselect2/compiler.py", "__init__")) + def test_refuses_remote_debugging(self, tmp_path: Path) -> None: guard = self.guard(tmp_path) assert guard.refuses("sys.remote_exec", (1, "x.py")) and guard.refuses("cpython.remote_debugger_script", ()) diff --git a/tests/unit/agents/runtime/test_render.py b/tests/unit/agents/runtime/test_render.py index a62e698507c..b8ae25ec22f 100644 --- a/tests/unit/agents/runtime/test_render.py +++ b/tests/unit/agents/runtime/test_render.py @@ -139,6 +139,26 @@ def test_harden_rejects(self, data: bytes, message: str) -> None: with pytest.raises(PngRejected, match=message): harden(data) + @staticmethod + def sparse_png(drawn_pixels: int) -> bytes: + """A 1000x100 background with `drawn_pixels` of ink in one strip of 10 rows: 100,000 pixels in all.""" + image = Image.new("RGB", (1000, 100), "#FAF8F1") + image.paste((0, 158, 115), (0, 0, drawn_pixels // 10, 10)) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + def test_a_sparse_render_passes_the_blank_check(self) -> None: + """99.5 % one colour, as sparse as the sparsest eligible pygal render (network-directed, 99.44 %): drawn.""" + hardened = harden(self.sparse_png(500)) + + assert (hardened.width, hardened.height) == (1000, 100) + + def test_a_canvas_with_only_a_title_is_blank(self) -> None: + """99.8 % one colour, the share of a pygal chart with only its title (99.78 %): nothing was drawn.""" + with pytest.raises(PngRejected, match=r"99\.8% one colour \(#FAF8F1\)"): + harden(self.sparse_png(200)) + def test_read_output_refuses_symlinks_and_size(self, tmp_path: Path) -> None: target = tmp_path / "secret.txt" target.write_text("host file") diff --git a/tests/unit/agents/runtime/test_runtimes.py b/tests/unit/agents/runtime/test_runtimes.py index f04af43f423..03adccbfe6f 100644 --- a/tests/unit/agents/runtime/test_runtimes.py +++ b/tests/unit/agents/runtime/test_runtimes.py @@ -20,7 +20,7 @@ class TestRegistry: def test_the_registry_serves_exactly_the_normalised_python_libraries(self) -> None: assert RUNTIME_LIBRARIES == PYTHON_LIBRARIES == NORMALISED_LIBRARIES - assert PYTHON_LIBRARIES == {"matplotlib", "seaborn", "plotnine", "altair"} + assert PYTHON_LIBRARIES == {"matplotlib", "seaborn", "plotnine", "altair", "pygal", "letsplot"} assert RUNTIME_LIBRARIES <= SUPPORTED_LIBRARIES @pytest.mark.parametrize("library", sorted(RUNTIME_LIBRARIES)) @@ -103,3 +103,48 @@ def test_plotnine_and_altair_runtimes_read_their_own_save_rule(self) -> None: assert plotnine.file_name == altair.file_name == "plot.py" and altair.code_artifact == "plot.py" changed = self.PLOTNINE.replace("dpi=400", "dpi=100") assert plotnine.restore_protected(self.PLOTNINE, changed) == self.PLOTNINE + + +class TestRowEnvironment: + """The local Docker backend renders with the library's command row as the renderer's `sandbox_env` does (1c).""" + + @pytest.mark.parametrize("library", sorted(RUNTIME_LIBRARIES)) + def test_the_render_env_is_the_renderers_key_for_key(self, library: str) -> None: + """The deployed `sandbox_env` of the library's row, but for the two paths of the image's font cache, which a + local render names as the image does, and the process limit, which the container's `--pids-limit` replaces + (`PythonRuntime.env`): the PATH the delegate check reads, the file size, the threads and every row value.""" + from agents.renderer.commands import command_for + from agents.renderer.executor import SANDBOX_PATH, ExecutorConfig, sandbox_env + from agents.renderer.settings import RendererSettings + + row = command_for("python", library) + local = runtime_for(library, cpu_seconds=60).env("dark") + deployed = sandbox_env(ExecutorConfig.from_settings(RendererSettings()), "dark", 60, command=row) + own_paths = {"MPLCONFIGDIR", "ANYPLOT_MPL_SEED"} + deployed.pop("ANYPLOT_RLIMIT_NPROC", None) + + assert set(local) == set(deployed) + assert {key: value for key, value in local.items() if key not in own_paths} == { + key: value for key, value in deployed.items() if key not in own_paths + } + assert local["PATH"] == SANDBOX_PATH and "ANYPLOT_RLIMIT_NPROC" not in local + assert ("ANYPLOT_RLIMIT_AS_MB" in local) is row.rlimit_as # altair's V8 runs without one + + def test_the_deployed_limits_are_the_renderers_defaults(self) -> None: + from agents.anyplot.render.runtimes.python import DATA_RLIMIT_AS_MB, DATA_RLIMIT_NPROC, RENDER_RLIMIT_FSIZE_MB + from agents.renderer.settings import RendererSettings + + deployed = RendererSettings() + assert (DATA_RLIMIT_AS_MB, DATA_RLIMIT_NPROC, RENDER_RLIMIT_FSIZE_MB) == ( + deployed.rlimit_as_mb, + deployed.rlimit_nproc, + deployed.rlimit_fsize_mb, + ) + + def test_lets_plot_gets_its_allocator_fontconfig_and_limit_locally(self) -> None: + env = runtime_for("letsplot", cpu_seconds=45).env("light") + assert env["ARROW_DEFAULT_MEMORY_POOL"] == "system" and env["FONTCONFIG_FILE"] == "/opt/anyplot/fonts.conf" + assert env["ANYPLOT_RLIMIT_AS_MB"] == "2048" and env["ANYPLOT_RLIMIT_CPU_S"] == "45" + + def test_a_runtime_without_a_library_renders_as_matplotlib(self) -> None: + assert PythonRuntime().env("light") == runtime_for("matplotlib").env("light")