Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
paths:
- '*/Dockerfile'
- '**/Dockerfile'
- 'langbot-sandbox/**'
- '.github/workflows/build.yml'
workflow_dispatch:
inputs:
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`)
Expand Down
19 changes: 19 additions & 0 deletions langbot-sandbox/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 26 additions & 0 deletions langbot-sandbox/configure_matplotlib.py
Original file line number Diff line number Diff line change
@@ -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")
6 changes: 6 additions & 0 deletions langbot-sandbox/matplotlibrc
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions langbot-sandbox/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
matplotlib==3.11.1
pandas==3.0.5
43 changes: 43 additions & 0 deletions langbot-sandbox/tests/smoke_test.py
Original file line number Diff line number Diff line change
@@ -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)
Loading