diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c61c9d1..f5bfcd1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,7 @@ on: paths: - '*/Dockerfile' - '**/Dockerfile' + - 'langbot-sandbox/**' - '.github/workflows/build.yml' workflow_dispatch: inputs: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..543af7a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Test sandbox image + +on: + pull_request: + paths: + - 'langbot-sandbox/**' + - '.github/workflows/test.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + sandbox: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - name: Build and smoke-test both architectures (no push) + uses: docker/build-push-action@v6 + with: + context: ./langbot-sandbox + platforms: linux/amd64,linux/arm64 + push: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/README.md b/README.md index 1e42a5d..54dfffb 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,42 @@ Each subdirectory contains a `Dockerfile` for one image. Pushing to `main` auto- |-----------|-------|-------------| | `langbot-sandbox` | `rockchin/langbot-sandbox` | Python 3.12 + Node 22 + common tools (git, vim, curl, wget, jq) for LangBot langbot-sandbox environments | +## Sandbox chart support + +`rockchin/langbot-sandbox` includes Matplotlib, pandas, and Noto CJK fonts. +Matplotlib defaults to the headless Agg backend and a CJK-capable sans-serif font, +so ordinary plotting scripts can render simplified/traditional Chinese without +installing packages or setting a font on every invocation. These defaults apply +to both root and non-root users. Scripts that explicitly replace the font settings +(including some styles) must select a CJK font themselves; `Noto Sans CJK JP` is +the family Matplotlib discovers in Debian's bundled Noto collection. + +The dependency versions are pinned in `langbot-sandbox/requirements.txt`. +Every image build runs `langbot-sandbox/tests/smoke_test.py`, checking pandas CSV +processing, default-font glyph coverage (including the Unicode minus), and real +Chinese PNG rendering with missing-glyph warnings treated as errors. Pull requests +build/test both amd64 and arm64 without publishing. + +To verify locally: + +```sh +docker build -t langbot-sandbox:test ./langbot-sandbox +docker run --rm --network none \ + -v "$PWD/langbot-sandbox/tests/smoke_test.py:/tmp/smoke_test.py:ro" \ + langbot-sandbox:test python /tmp/smoke_test.py +``` + +Existing installations must pull the updated image on the Docker host used by +LangBot Box, then recreate their **sandbox sessions/containers**. Updating LangBot +alone or restarting an existing sandbox does not replace its image: + +```sh +docker pull rockchin/langbot-sandbox:latest +``` + +Back up any needed files installed or written only inside an old container before +recreating it. Bind-mounted workspace data is separate from the container image. + ## Adding a new image 1. Create a new directory (e.g. `my-image/`) diff --git a/langbot-sandbox/Dockerfile b/langbot-sandbox/Dockerfile index 75a881c..b773cb6 100644 --- a/langbot-sandbox/Dockerfile +++ b/langbot-sandbox/Dockerfile @@ -8,6 +8,25 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ openssh-client \ jq \ unzip \ + fontconfig \ + fonts-noto-cjk \ + && fc-cache -f \ && rm -rf /var/lib/apt/lists/* +COPY requirements.txt /tmp/sandbox-requirements.txt +RUN python -m pip install --no-cache-dir -r /tmp/sandbox-requirements.txt \ + && rm /tmp/sandbox-requirements.txt + +# Use Matplotlib's installation-wide defaults rather than a root-only config +# or a shared writable MPLCONFIGDIR. User/script overrides still take priority. +COPY matplotlibrc configure_matplotlib.py /tmp/ +RUN python /tmp/configure_matplotlib.py /tmp/matplotlibrc \ + && rm /tmp/configure_matplotlib.py /tmp/matplotlibrc + +# Exercise real rendering on every architecture before an image can be pushed. +COPY tests/smoke_test.py /tmp/sandbox-smoke-test.py +RUN python /tmp/sandbox-smoke-test.py \ + && rm /tmp/sandbox-smoke-test.py \ + && rm -rf /root/.cache/matplotlib + WORKDIR /workspace diff --git a/langbot-sandbox/configure_matplotlib.py b/langbot-sandbox/configure_matplotlib.py new file mode 100644 index 0000000..82a362f --- /dev/null +++ b/langbot-sandbox/configure_matplotlib.py @@ -0,0 +1,26 @@ +"""Customize the bundled rc template without deleting required defaults.""" + +from pathlib import Path +import re +import sys + +import matplotlib + + +template = Path(matplotlib.get_data_path()) / "matplotlibrc" +content = template.read_text(encoding="utf-8") +for line in Path(sys.argv[1]).read_text(encoding="utf-8").splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + key, value = line.split(":", 1) + # Matplotlib derives rcParamsDefault from this entire template, including + # commented settings. Replacing it with a small user rc file breaks import. + content, count = re.subn( + rf"^#*\s*{re.escape(key)}:.*$", + f"{key}:{value}", + content, + flags=re.MULTILINE, + ) + if count != 1: + raise RuntimeError(f"Expected exactly one template setting for {key}, got {count}") +template.write_text(content, encoding="utf-8") diff --git a/langbot-sandbox/matplotlibrc b/langbot-sandbox/matplotlibrc new file mode 100644 index 0000000..ea05565 --- /dev/null +++ b/langbot-sandbox/matplotlibrc @@ -0,0 +1,6 @@ +# Image-wide defaults, including when the sandbox runs as a non-root user. +# Matplotlib discovers the first face in Debian's Noto CJK collection as JP. +# That face also covers simplified/traditional Chinese and the Unicode minus. +backend: Agg +font.family: sans-serif +font.sans-serif: Noto Sans CJK JP, DejaVu Sans diff --git a/langbot-sandbox/requirements.txt b/langbot-sandbox/requirements.txt new file mode 100644 index 0000000..480352e --- /dev/null +++ b/langbot-sandbox/requirements.txt @@ -0,0 +1,2 @@ +matplotlib==3.11.1 +pandas==3.0.5 diff --git a/langbot-sandbox/tests/smoke_test.py b/langbot-sandbox/tests/smoke_test.py new file mode 100644 index 0000000..56eec9f --- /dev/null +++ b/langbot-sandbox/tests/smoke_test.py @@ -0,0 +1,43 @@ +"""Run inside a fresh sandbox without installing packages or selecting a font.""" + +import io +import os +from pathlib import Path +import unittest +import warnings + + +class SandboxChartTests(unittest.TestCase): + def test_pandas_is_preinstalled(self): + import pandas as pd + + frame = pd.read_csv(io.StringIO("月份,收入\n一月,-2\n二月,3\n")) + self.assertEqual(frame["收入"].sum(), 1) + + def test_default_matplotlib_renders_chinese(self): + import matplotlib + import matplotlib.pyplot as plt + from matplotlib import font_manager, ft2font + + self.assertEqual(matplotlib.get_backend().lower(), "agg") + font_path = font_manager.findfont(font_manager.FontProperties()) + glyphs = ft2font.FT2Font(font_path).get_charmap() + text = "中文图表月份收入繁體測試−" + self.assertTrue(all(ord(char) in glyphs for char in text), font_path) + with warnings.catch_warnings(): + warnings.filterwarnings("error", message=r"Glyph .* missing from font") + fig, ax = plt.subplots() + ax.plot([-1, 0, 1], [-2, 0, 3], label="收入") + ax.set(title="中文图表 / 繁體測試", xlabel="月份", ylabel="收入") + ax.legend() + output = io.BytesIO() + fig.savefig(output, format="png") + plt.close(fig) + self.assertTrue(output.getvalue().startswith(b"\x89PNG\r\n\x1a\n")) + if target := os.environ.get("CHART_TEST_OUTPUT"): + Path(target).write_bytes(output.getvalue()) + print(f"Default font: {font_path}; PNG bytes: {len(output.getvalue())}") + + +if __name__ == "__main__": + unittest.main(verbosity=2)