Rewrite gallery as an installable package, add plotstyle, migrate build/CI to uv #1

Merged
lars merged 36 commits from dev into main 2026-07-24 10:49:38 +02:00
54 changed files with 5608 additions and 2919 deletions
+14
View File
@@ -0,0 +1,14 @@
.git
.venv
*.sif
*.ipynb
__pycache__
*.pyc
*.pyo
.pytest_cache
build
*.egg-info
.claude
config.yaml
docs/
CLAUDE.md
+80
View File
@@ -0,0 +1,80 @@
name: CI
on:
push:
pull_request:
jobs:
lint:
name: lint:ruff
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v9.0.0
with:
python-version: "3.11"
enable-cache: true
- run: uv sync --all-packages
- name: ruff check gallery
run: uv run ruff check gallery
- name: ruff check plotstyle
run: uv run ruff check plotstyle
- name: ruff check tests
run: uv run ruff check tests
format:
name: format:ruff
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v9.0.0
with:
python-version: "3.11"
enable-cache: true
- run: uv sync --all-packages
- name: ruff format check gallery
run: uv run ruff format --check gallery
- name: ruff format check plotstyle
run: uv run ruff format --check plotstyle
- name: ruff format check tests
run: uv run ruff format --check tests
typecheck:
name: typecheck:ty
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v9.0.0
with:
python-version: "3.11"
enable-cache: true
- run: uv sync --all-packages
- name: ty check gallery
run: uv run ty check gallery
- name: ty check plotstyle
run: uv run ty check plotstyle
vulnerabilities:
name: vulnerabilities:pip-audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v9.0.0
with:
python-version: "3.11"
enable-cache: true
- run: uv sync --all-packages
- run: uv run pip-audit --skip-editable
test:
name: test:pytest
runs-on: ubuntu-latest
needs: [lint, format, typecheck, vulnerabilities]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v9.0.0
with:
python-version: "3.11"
enable-cache: true
- run: uv sync --all-packages
- run: uv run pytest tests/ -v
+39
View File
@@ -0,0 +1,39 @@
name: Publish plotstyle
on:
push:
tags:
- "plotstyle-v*"
permissions:
contents: read
packages: write
jobs:
publish:
name: build-and-publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v9.0.0
with:
python-version: "3.11"
- name: Verify tag matches package version
run: |
tag_version="${GITHUB_REF_NAME#plotstyle-v}"
pkg_version="$(uv version --short --package plotstyle)"
if [ "$tag_version" != "$pkg_version" ]; then
echo "Tag plotstyle-v$tag_version does not match pyproject.toml version $pkg_version" >&2
exit 1
fi
- name: Build wheel and sdist
run: uv build --package plotstyle -o dist
- name: Publish to Gitea package registry
env:
UV_PUBLISH_USERNAME: ${{ github.actor }}
UV_PUBLISH_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
run: uv publish --publish-url "${{ github.server_url }}/api/packages/${{ github.repository_owner }}/pypi" dist/*
+1
View File
@@ -2,6 +2,7 @@
.vscode
*.sif
*.ipynb
!examples/*.ipynb
backups
.pytest_cache
.venv
-65
View File
@@ -1,65 +0,0 @@
# GitLab CI/CD Pipeline for Gallery Generator
# Builds Apptainer container and runs tests inside it
stages:
- build
- test
variables:
CONTAINER_IMAGE: "gallery-generator.sif"
APPTAINER_CACHE_DIR: "$CI_PROJECT_DIR/.apptainer-cache"
# Cache to speed up builds
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .apptainer-cache/
# Build the Apptainer container
build:container:
stage: build
tags:
- apptainer
script:
- echo "Building Apptainer container..."
- apptainer --version
- apptainer build --fakeroot $CONTAINER_IMAGE Singularity.def
- ls -lh $CONTAINER_IMAGE
artifacts:
paths:
- $CONTAINER_IMAGE
expire_in: 1 hour
# Run tests inside the container
test:pytest:
stage: test
tags:
- apptainer
dependencies:
- build:container
script:
- echo "Running pytest inside container..."
- apptainer exec $CONTAINER_IMAGE pytest /src/tests/ -v
artifacts:
when: always
# Run tests with coverage
test:coverage:
stage: test
tags:
- apptainer
dependencies:
- build:container
script:
- echo "Running coverage analysis inside container..."
- apptainer exec $CONTAINER_IMAGE pytest /src/tests/ --cov=/src --cov-report=xml --cov-report=term
- apptainer exec $CONTAINER_IMAGE cat /src/coverage.xml > coverage.xml || echo "No coverage.xml found"
coverage: '/TOTAL.*\s+(\d+%)$/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths:
- coverage.xml
expire_in: 30 days
+39 -12
View File
@@ -6,41 +6,55 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
A Python package that generates responsive static HTML galleries from scientific plot collections (PDFs and HTMLs). It converts PDFs to PNGs via ImageMagick, organizes plots hierarchically, propagates YAML/JSON metadata through directory trees, and renders everything via a Jinja2 template into a static website served from a web directory.
The repo also ships `plotstyle`, a standalone matplotlib styling toolkit (KIT corporate-design theme + building-block functions) for producing the PDF figures that feed into a `gallery` source directory. `gallery` never imports it — the only connection is the PDF files and `metadata.yaml` on disk. **See `plotstyle/CLAUDE.md` for full agent-facing usage docs, the API reference, and the metadata.yaml workflow.**
This repo is a **uv workspace** (root `pyproject.toml` has `[tool.uv.workspace] members = ["plotstyle"]`) and both projects build with uv's own `uv_build` backend — there is no setuptools anywhere in this repo. Use `uv`/`uv run` for everything; don't reach for `pip install` here.
## Commands
```bash
# Install the package (editable)
pip install -e ".[dev]"
# Install everything (gallery + plotstyle + dev tools) into the shared workspace venv
uv sync --all-packages
# Run all tests
pytest tests/
uv run pytest tests/
# Run a single test file
pytest tests/test_generate_gallery.py -v
uv run pytest tests/test_generate_gallery.py -v
uv run pytest tests/test_plotstyle.py -v
# Run a single test by name
pytest tests/test_generate_gallery.py::test_needs_update_missing_target -v
uv run pytest tests/test_generate_gallery.py::test_needs_update_missing_target -v
# Generate gallery
gallery generate --verbose
uv run gallery generate --verbose
# Generate with a non-default config
gallery --config config.yaml generate --verbose
uv run gallery --config config.yaml generate --verbose
# Incremental update for one source only
gallery generate --source /path/to/plots --verbose
uv run gallery generate --source /path/to/plots --verbose
# Clean regeneration
gallery generate --clean --verbose
uv run gallery generate --clean --verbose
# Launch TUI
gallery tui
uv run gallery tui
# Serve output locally
python -m http.server 8000 -d /web/kschmidt/public_html/
```
Code style: black with `line-length = 120`.
Code style: ruff (lint + format), `line-length = 120`. Type-checked with `ty`.
```bash
uv run ruff check gallery plotstyle tests
uv run ruff format gallery plotstyle tests
uv run ty check gallery plotstyle
uv run pip-audit --skip-editable
```
**Before committing**, run the same checks CI (`.gitea/workflows/ci.yml`) runs and make sure they pass — `ruff check`, `ruff format --check`, `ty check`, `pip-audit`, and `pytest tests/`. Catching a failure locally is faster than waiting on the pipeline.
## Architecture
@@ -75,6 +89,9 @@ generate() [api.py]
| `gallery/assets/js/` | Vanilla JS modules loaded as ES modules; `GalleryApp` in `gallery-app.js` orchestrates all managers |
| `gallery/assets/css/` | Modular CSS; `main.css` imports all others via `@import` |
| `config.yaml` | Local deployment config (paths are machine-specific) |
| `plotstyle/` | Standalone matplotlib styling toolkit for producing plots (see `plotstyle/CLAUDE.md`) — not imported by `gallery/`; a separate uv workspace member with its own `pyproject.toml`, code under `plotstyle/src/plotstyle/` (src layout) |
| `.gitea/workflows/publish-plotstyle.yml` | Builds and publishes `plotstyle` to the Gitea package registry on `plotstyle-v*` tags |
| `examples/plotstyle_showcase.ipynb` | Rendered, runnable tour of every `plotstyle` function |
### Config File Format
@@ -104,10 +121,20 @@ When `source_to_update` is passed to `generate()`, only that source's subdirecto
`metadata.yaml` (or `.yml`/`.json`) in any source directory is loaded and **merged with parent metadata** (`inherit_from_parent=True` by default). Child directories override parent keys. Per-plot overrides can live in `<plotname>.yaml` files alongside the plot.
Fields are freeform YAML (no fixed schema); `title`, `description`, `plot_type`, `experiment` get prominent placement in the per-plot metadata popup, everything else still displays under "Additional Information". Text values support inline LaTeX rendered via MathJax client-side.
### Plot-Producing Companion (`plotstyle`)
`plotstyle` (a separate uv workspace member, see below) is how plots destined for a `gallery` source directory should be produced — a KIT corporate-design matplotlib theme plus building blocks (`new_figure`, `colorbar`, `style_legend`, `panel_label`, `savefig`). It has no code dependency on `gallery`; the two only meet on disk, via the PDFs and `metadata.yaml` files a `plotstyle` script writes into a `gallery` source directory. **Full usage docs, API reference, best practices, and the metadata.yaml workflow live in `plotstyle/CLAUDE.md`** — read that file before writing or reviewing any script that `import plotstyle`. `examples/plotstyle_showcase.ipynb` is a rendered, runnable tour of every function.
`plotstyle` has its own `plotstyle/pyproject.toml` (own `uv_build` project, code lives in `plotstyle/src/plotstyle/`) and is a member of this repo's uv workspace (`[tool.uv.workspace] members = ["plotstyle"]` in the root `pyproject.toml`) — `uv sync --all-packages` installs both `gallery` and `plotstyle` (and matplotlib) into one shared venv, which is why `import plotstyle` works from `tests/` and the example notebook without `gallery` ever depending on it. It's also independently publishable: pushing a tag matching `plotstyle-v*` (e.g. `plotstyle-v0.1.0`) runs `.gitea/workflows/publish-plotstyle.yml`, which runs `uv build --package plotstyle` and `uv publish` to this repo's Gitea package registry (`{server}/api/packages/{owner}/pypi`) using the workflow's auto-generated token. The tag's version suffix must match `plotstyle/pyproject.toml`'s `version` field or the workflow fails fast — bump that version before tagging a new release.
### Frontend (Static JS/CSS)
The frontend is vanilla ES modules — no build step. `assets/js/main.js` imports `GalleryApp` from `gallery-app.js`, which instantiates all manager classes (`ThemeManager`, `SearchManager`, `NavigationManager`, etc.). Each manager is self-contained. The template embeds gallery data as JSON in the page; JS reads it at runtime.
### Deployment
The project ships a `Singularity.def` / `web.sif` Apptainer container for HPC environments. CI (`.gitlab-ci.yml`) builds the container and runs pytest inside it. For local development the `.venv` is sufficient.
The project ships a `Dockerfile` plus `docker-compose.yml` (a `generator` service that runs `gallery generate` on an interval, and an `nginx`-based `web` service serving the output — see `deploy/entrypoint.sh` and `deploy/nginx.conf`). This suits a dedicated VM/server you fully control. The `Dockerfile` is a two-stage build: a builder stage copies in the official `ghcr.io/astral-sh/uv` binary and runs `uv build --package gallery` to produce a wheel, then the final stage `pip install`s just that wheel (no dev deps, no workspace/lockfile needed at runtime). CI (`.gitea/workflows/ci.yml`, run via Gitea Actions) uses `astral-sh/setup-uv` + `uv sync --all-packages` and runs every check (`ruff check`, `ruff format --check`, `ty check`, `pip-audit`, `pytest`) via `uv run` — no plain `pip`/`actions/setup-python` anywhere in CI. For local development, `uv sync --all-packages` is all you need.
On shared HPC login nodes without a Docker daemon (e.g. KIT ETP, where `public_html` is already auto-served) use a plain venv install plus a `systemd --user` timer instead — see `deploy/systemd/README.md`.
+30
View File
@@ -0,0 +1,30 @@
FROM python:3.11-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
COPY plotstyle/pyproject.toml plotstyle/README.md plotstyle/
COPY gallery/ gallery/
RUN uv build --package gallery -o dist
FROM python:3.11-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends imagemagick \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/dist/*.whl /tmp/
COPY deploy/entrypoint.sh deploy/entrypoint.sh
RUN pip install --no-cache-dir /tmp/*.whl && rm /tmp/*.whl
RUN useradd -r -s /bin/false gallery
USER gallery
ENTRYPOINT ["gallery"]
CMD ["generate"]
+51 -3
View File
@@ -31,15 +31,19 @@
- TUI: `gallery tui`
- Config file stored under user `$HOME/.config/gallery`
### Producing Plots (`plotstyle`)
- Companion matplotlib styling toolkit (optional `plotting` extra) for producing figures that look consistent across a thesis and slide deck, ready to drop straight into a gallery source directory — see [Producing Plots with `plotstyle`](#producing-plots-with-plotstyle)
## Installation
### From GitLab
### From Gitea
Pip install:
```bash
pip install git+https://gitlab.etp.kit.edu/kschmidt/web
pip install git+https://git.larsbogner.de/lars/ETPlot
```
Or git clone and `pip install .`. After installation the `gallery` command is available in your shell. Verify with:
@@ -54,6 +58,20 @@ gallery --help
- Python packages are installed automatically by pip (Jinja2, PyYAML, PyMuPDF, Textual, argcomplete, platformdirs)
- [ImageMagick](https://imagemagick.org/) is **optional** — used as a fallback if `PyMuPDF` is not available
### Optional: `plotstyle` (for producing plots)
`plotstyle` is a separate package (its own `plotstyle/pyproject.toml`, a member of this repo's uv workspace) — installing `gallery` alone never pulls in `matplotlib`.
```bash
# Working in this repo (installs gallery + plotstyle + matplotlib together):
uv sync --all-packages
# Just the plotstyle library, standalone:
pip install "plotstyle @ git+https://git.larsbogner.de/lars/ETPlot#subdirectory=plotstyle"
```
`plotstyle` also requires a local LaTeX toolchain (`latex` + `dvipng`) to be installed separately; see [Producing Plots with `plotstyle`](#producing-plots-with-plotstyle).
### Shell Completion (optional)
Install tab-completion for bash/zsh/fish:
@@ -170,7 +188,6 @@ paths:
gallery:
plot_root: "gallery" # subdirectory inside web_folder
png_dpi: 400 # thumbnail resolution
backup_folder: "" # optional backup path
sources:
- name: "analysis_results"
@@ -215,6 +232,37 @@ LaTeX formulas are supported in metadata values and rendered with MathJax:
formula: "$$E = mc^2$$"
```
## Producing Plots with `plotstyle`
`plotstyle` is a companion matplotlib styling toolkit shipped in this repo (the `plotstyle/` package) for producing the plots you'll point a gallery source at — a KIT (Karlsruhe Institute of Technology) corporate-design color palette, consistent spines/ticks/gridlines, LaTeX text in a modern sans font, and a few building-block functions (figure titles with a parameters subtitle, a same-size colorbar helper, an outside-axes legend, panel labels). It has no code dependency on `gallery` — the two only meet on disk, through the PDF files (and optional `metadata.yaml`) a `plotstyle` script writes into a directory that `gallery` then scans.
Install it with the `plotting` extra (see [Installation](#installation)) and make sure a LaTeX toolchain (`latex` + `dvipng`) is available locally — `plotstyle` always renders text through real LaTeX, there's no fallback.
```python
import numpy as np
import plotstyle as ps
ps.use() # once, before creating any figure
fig, ax = ps.new_figure(
"thesis-single",
title="Measured signal",
params={"N": 512, "seed": 42},
)
x = np.linspace(0, 10, 200)
ax.plot(x, np.sin(x), label="signal")
ax.set_xlabel("Time (s)")
ax.set_ylabel(r"Amplitude $A(t)$")
ps.style_legend(ax, title="Series")
# Save straight into a gallery source directory:
ps.savefig(fig, "/path/to/plots/measured_signal", formats=("pdf",))
```
That PDF (plus an optional `metadata.yaml` next to it, as described above) is exactly what `gallery generate --source /path/to/plots` picks up — `gallery` converts the PDF to a thumbnail PNG itself, so `plotstyle` scripts should stick to `formats=("pdf",)` rather than also producing a PNG.
See `examples/plotstyle_showcase.ipynb` for a fully rendered tour of every function, and `plotstyle/CLAUDE.md` for the full API reference and best practices (aimed at coding agents, but equally useful for humans).
## Shortcuts
| Icon | Button | Function | Shortcut |
-20
View File
@@ -1,20 +0,0 @@
Bootstrap: docker
From: python:3.11-slim
%post
apt-get update
apt-get install -y --no-install-recommends imagemagick
rm -rf /var/lib/apt/lists/*
pip install --no-cache-dir jinja2 pyyaml coverage pytest pytest-cov
mkdir -p /src
%files
. /src
%environment
export PYTHONPATH=/src
%runscript
cd /src
exec python3 generate_gallery.py "$@"
+36
View File
@@ -0,0 +1,36 @@
# Gallery Configuration for lbogner (KIT ETP HPC login nodes)
# ==============================================================
# Deployed via plain venv + systemd --user timer (see deploy/systemd/README.md),
# not Docker/Compose — this path has no Docker daemon, and /web/lbogner/public_html
# is already served by KIT's own web infrastructure.
paths:
# Working directory where the script runs from
work_dir: "/work/lbogner/web"
# Web hosting directory where gallery files are served (auto-served by KIT)
web_folder: "/web/lbogner/public_html/"
gallery:
plot_root: "gallery"
png_dpi: 400
ui:
max_recent_plots: 20
search_debounce_ms: 300
metadata:
cache_enabled: true
inherit_from_parent: true
# Data Sources
# TODO: fill in your actual plot-producing repo output directories under
# /work/lbogner/... and/or plot data under /ceph/... (adjust names/paths to match
# where each cloned repo's plotstyle scripts actually write their PDFs).
sources:
- name: "ttbar_analysis"
path: "/work/lbogner/PLACEHOLDER/ttbar_analysis/plots"
- name: "needle_benchmarks"
path: "/work/lbogner/PLACEHOLDER/needle/benchmarks/plots"
- name: "aido_convergence_study"
path: "/work/lbogner/PLACEHOLDER/aido/results_convergence/plots"
-3
View File
@@ -17,9 +17,6 @@ gallery:
# PNG conversion quality
png_dpi: 400
# Backup folder (leave empty to disable)
backup_folder: ""
# UI Settings
ui:
# Maximum number of recent plots to track
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -e
CONFIG_FILE="${CONFIG_FILE:-/config/config.yaml}"
GENERATE_INTERVAL="${GENERATE_INTERVAL:-300}" # seconds between regenerations; 0 = run once and exit
if [ ! -f "$CONFIG_FILE" ]; then
echo "ERROR: config file not found at $CONFIG_FILE" >&2
exit 1
fi
run_generate() {
echo "[$(date -u +%FT%TZ)] Running gallery generate..."
gallery --config "$CONFIG_FILE" generate --verbose
}
run_generate
if [ "$GENERATE_INTERVAL" -eq 0 ]; then
exit 0
fi
while true; do
sleep "$GENERATE_INTERVAL"
run_generate
done
+40
View File
@@ -0,0 +1,40 @@
server {
listen 80;
server_name _;
root /var/www/gallery;
index index.html;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# Static assets: cache aggressively
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
# HTML: no cache so regenerated galleries are picked up immediately
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store";
}
location / {
try_files $uri $uri/ $uri/index.html =404;
}
# Deny access to hidden files
location ~ /\. {
deny all;
}
}
+62
View File
@@ -0,0 +1,62 @@
# Deploying on a KIT HPC login node (no Docker daemon)
This path is for machines like the ETP login nodes: `/ceph`, `/work`, `/web` are
mounted directly, but there's no Docker daemon available, and your `public_html`
is already served by KIT's own web infrastructure — so no web server needs to
run here at all, only the generator on a schedule.
## 1. Install into a venv
```bash
python3 -m venv ~/.venvs/gallery
~/.venvs/gallery/bin/pip install -e /work/lbogner/ETPlot # path to your clone
```
PyMuPDF (a base dependency) handles PDF→PNG conversion; ImageMagick is not required.
## 2. Point a config at your paths
Copy/edit `config.lbogner.yaml` from the repo root (already has `web_folder:
/web/lbogner/public_html/` and placeholder `sources:` — fill in the real
plot-output directories under `/work/lbogner/...` or `/ceph/...`). Put it
wherever you like, e.g. `~/gallery/config.lbogner.yaml`, and adjust the path in
`gallery-generate.service` if you move it.
## 3. Install the systemd --user timer
```bash
mkdir -p ~/.config/systemd/user
cp deploy/systemd/gallery-generate.{service,timer} ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now gallery-generate.timer
```
Check status/logs:
```bash
systemctl --user list-timers gallery-generate.timer
journalctl --user -u gallery-generate.service -f
```
By default a user's systemd instance (and its timers) stops when you log out.
Enable lingering so it keeps running:
```bash
loginctl enable-linger $USER
```
If lingering isn't permitted on your login node, or `systemctl --user` isn't
usable there at all, fall back to a crontab entry instead:
```
*/5 * * * * ~/.venvs/gallery/bin/gallery --config ~/gallery/config.lbogner.yaml generate >> ~/gallery-generate.log 2>&1
```
## Why not the Docker Compose stack?
`docker-compose.yml` at the repo root (generator + nginx) is for a scenario
where you control a dedicated VM/server and need to serve the output yourself.
On the ETP login nodes there's no Docker daemon (Apptainer/Singularity only),
and `public_html` is already auto-served — running your own nginx there would
be redundant. It remains a fine option for anyone deploying this on their own
server.
+7
View File
@@ -0,0 +1,7 @@
[Unit]
Description=Generate ETPlot gallery
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=%h/.venvs/gallery/bin/gallery --config %h/gallery/config.lbogner.yaml generate --verbose
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Periodically regenerate the ETPlot gallery
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.target
+42
View File
@@ -0,0 +1,42 @@
services:
generator:
build: .
image: etplot-gallery:latest
entrypoint: ["/app/deploy/entrypoint.sh"]
environment:
CONFIG_FILE: /config/config.yaml
# How often to regenerate the gallery (seconds). Set to 0 to run once and exit.
GENERATE_INTERVAL: "300"
volumes:
- ./config.yaml:/config/config.yaml:ro
# Mount your plot source directories here, matching the paths in config.yaml.
# Example:
# - /path/to/plots:/plots:ro
- gallery_output:/var/www/gallery
restart: unless-stopped
healthcheck:
test: ["CMD", "gallery", "--help"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
web:
image: nginx:1.27-alpine
ports:
- "${HTTP_PORT:-8080}:80"
volumes:
- ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- gallery_output:/var/www/gallery:ro
depends_on:
- generator
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
gallery_output:
-275
View File
@@ -1,275 +0,0 @@
# Automated Coverage Testing Documentation
## Overview
This repository now includes automated code coverage testing using the `coverage.py` package. Coverage testing helps ensure that your tests adequately exercise your codebase and identifies untested code paths.
## 🚀 Quick Start
### Container-based Coverage (Recommended)
```bash
# Build and test with coverage in container
./tests/test_container.sh
# Or run coverage directly in container
apptainer exec gallery-generator.sif python3 /src/tests/run_coverage.py
```
### Local Coverage Testing
```bash
# Run coverage tests locally
./tests/run_coverage_local.sh
# Or manually
pip install coverage
coverage run -m unittest tests.test_container
coverage report
coverage html
```
## 📁 Coverage Files
### Core Coverage Files
- **`.coveragerc`** - Coverage configuration file
- **`tests/run_coverage.py`** - Automated coverage script for containers
- **`tests/run_coverage_local.sh`** - Local coverage testing script
### Generated Reports
- **`coverage.xml`** - XML format for CI/CD integration
- **`coverage_html_report/`** - Interactive HTML reports
- **`.coverage`** - Coverage data file
## 🔧 Configuration
### Coverage Settings (`.coveragerc`)
```ini
[run]
source = .
omit =
tests/* # Exclude test files
__pycache__/* # Exclude cache
assets/* # Exclude static assets
docs/* # Exclude documentation
templates/* # Exclude templates
[report]
precision = 2 # 2 decimal places
show_missing = True # Show missing line numbers
skip_covered = False # Show all files
[html]
directory = coverage_html_report
title = Gallery Generator Coverage Report
```
### Singularity Container Integration
The coverage package is automatically installed in the container:
```bash
pip install --no-cache-dir jinja2 pyyaml coverage
```
## 📊 Coverage Reports
### Console Report
Shows coverage percentage and missing lines:
```
Name Stmts Miss Cover Missing
-----------------------------------------------------
generate_gallery.py 190 45 76.32% 156-167, 234-245
orchestration/config.py 45 8 82.22% 78-82
orchestration/logger.py 67 12 82.09% 45-48, 89-94
-----------------------------------------------------
TOTAL 302 65 78.48%
```
### HTML Report
Interactive report with:
- Line-by-line coverage highlighting
- Branch coverage details
- Sortable file listings
- Coverage trends
### XML Report
Machine-readable format for CI/CD:
- GitLab CI coverage visualization
- External tool integration
- Coverage badges
## 🎯 Coverage Targets
### Current Thresholds
- **Minimum Target**: 80% overall coverage
- **Warning Level**: Below 70% coverage
- **Exclusions**: Test files, static assets, documentation
### Best Practices
- **Focus on Core Logic**: Prioritize business logic coverage
- **Test Edge Cases**: Include error handling and boundary conditions
- **Regular Monitoring**: Run coverage with every commit
- **Incremental Improvement**: Gradually increase coverage over time
## 🔄 CI/CD Integration
### GitLab CI Pipeline
The coverage testing is integrated into the GitLab CI pipeline:
```yaml
test:coverage:
stage: test
script:
- apptainer exec $CONTAINER_IMAGE python3 /src/tests/run_coverage.py
coverage: '/TOTAL.+?(\d+\.\d+)%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
```
### Features
- **Automatic Reports**: Coverage reports in merge requests
- **Badge Integration**: Coverage badges in README
- **Trend Tracking**: Historical coverage data
- **Failure Thresholds**: Fail builds below minimum coverage
## 🛠️ Advanced Usage
### Custom Coverage Runs
```bash
# Test specific modules
coverage run --source=orchestration -m unittest tests.test_metadata
# Include/exclude patterns
coverage run --omit="*/tests/*" -m unittest discover
# Branch coverage (more detailed)
coverage run --branch -m unittest tests.test_container
```
### Coverage Analysis
```bash
# Show missing lines
coverage report --show-missing
# Generate detailed HTML
coverage html --show-contexts
# Export data
coverage json
coverage xml
```
### Integration with IDEs
- **VS Code**: Coverage Gutters extension
- **PyCharm**: Built-in coverage runner
- **Vim**: Coverage highlighting plugins
## 📈 Coverage Metrics
### What Coverage Measures
- **Statement Coverage**: Lines of code executed
- **Branch Coverage**: Decision paths taken
- **Function Coverage**: Functions called
- **Class Coverage**: Classes instantiated
### What Coverage Doesn't Measure
- **Code Quality**: Coverage ≠ good tests
- **Logic Correctness**: 100% coverage ≠ bug-free
- **Performance**: Execution speed not measured
- **Security**: Vulnerabilities not detected
## 🧪 Testing Strategy
### Container Test Suite Coverage
Current test files and their focus:
#### `tests/test_container.py`
- **Environment validation** - Container setup
- **Utility functions** - Helper functions
- **Metadata system** - YAML processing
- **PDF processing** - ImageMagick integration
- **Gallery generation** - End-to-end workflow
#### `tests/test_build_container.py`
- **Container building** - Singularity build process
- **Dependency validation** - Package installation
- **Application functionality** - Script execution
### Coverage Gaps Analysis
Use `tests/test_coverage.py` to analyze:
- Missing function coverage
- Untested code paths
- Critical functionality gaps
- Integration test needs
## 🚨 Troubleshooting
### Common Issues
#### No Coverage Data
```bash
# Ensure coverage is running tests
coverage run --debug=trace -m unittest tests.test_container
```
#### Import Errors
```bash
# Check PYTHONPATH
export PYTHONPATH=/src:$PYTHONPATH
```
#### Permission Issues
```bash
# Container write permissions
apptainer exec --writable-tmpfs container.sif python3 tests/run_coverage.py
```
### Debug Commands
```bash
# Check coverage configuration
coverage debug config
# Verify data collection
coverage debug data
# Test discovery
coverage debug sys
```
## 📚 References
- **Coverage.py Documentation**: https://coverage.readthedocs.io/
- **GitLab CI Coverage**: https://docs.gitlab.com/ee/ci/testing/code_coverage.html
- **Testing Best Practices**: Python Testing 101
- **Container Testing**: Singularity/Apptainer Documentation
## 🔄 Maintenance
### Regular Tasks
- **Weekly**: Review coverage reports
- **Monthly**: Update coverage targets
- **Release**: Ensure minimum coverage met
- **Quarterly**: Review exclusion patterns
### Cleanup
```bash
# Remove coverage files
./tests/cleanup.sh
# Manual cleanup
rm -f .coverage coverage.xml
rm -rf coverage_html_report/
```
### Updates
```bash
# Update coverage package
pip install --upgrade coverage
# Update container
apptainer build --force container.sif Singularity.def
```
---
*This automated coverage system provides comprehensive testing insights while maintaining the containerized, dependency-free approach of the gallery generator project.*
-136
View File
@@ -1,136 +0,0 @@
# Gallery Sort Functionality Implementation Guide
## Overview
This guide explains how to integrate the new sorting functionality that allows users to sort plots by name and creation time.
## Frontend Implementation (Complete ✅)
The frontend implementation is complete and includes:
### 1. Sort Controls UI
- **Name/Time buttons**: Toggle between sorting by filename and creation time
- **Order button**: Toggle between ascending (↑) and descending (↓) order
- **Positioned**: Left side of the controls container, next to view toggle buttons
- **Responsive**: Adapts to mobile layouts
### 2. Keyboard Shortcuts
- `Ctrl+N`: Sort by name
- `Ctrl+M`: Sort by time (modification/creation time)
- `Ctrl+O`: Toggle sort order (ascending/descending)
### 3. Persistence
- Sort preferences are saved to localStorage
- Settings persist across page reloads and navigation
### 4. Tile Sizing
- Grid view now shows ~6 plots per row on desktop (240px minimum width)
- Responsive design maintains usability on mobile devices
## Backend Integration (Required)
To enable time-based sorting, you need to modify your Python gallery generation code:
### 1. Add Creation Time to Plot Items
```python
from pathlib import Path
def add_creation_time_to_items(items, base_path):
"""Add creation time to plot items for sorting functionality."""
for item in items:
try:
# Get creation time from PNG or PDF file
png_path = None
pdf_path = None
if 'png_href' in item:
png_rel_path = item['png_href'].replace('../', '').replace('./', '')
png_path = Path(base_path) / png_rel_path
if 'pdf_href' in item:
pdf_rel_path = item['pdf_href'].replace('../', '').replace('./', '')
pdf_path = Path(base_path) / pdf_rel_path
# Use PNG creation time if available, otherwise PDF
creation_time = 0
if png_path and png_path.exists():
creation_time = int(png_path.stat().st_ctime)
elif pdf_path and pdf_path.exists():
creation_time = int(pdf_path.stat().st_ctime)
item['creation_time'] = creation_time
except Exception as e:
print(f"Warning: Could not get creation time for {item.get('name', 'unknown')}: {e}")
item['creation_time'] = 0
return items
```
### 2. Integrate into Your Gallery Generation
In your existing gallery generation code, call this function before rendering the template:
```python
# Your existing code that creates the items list
items = generate_plot_items() # Your existing function
# Add creation times
items = add_creation_time_to_items(items, gallery_base_path)
# Pass to template
template.render(items=items, ...)
```
### 3. Template Data Structure
The template now expects each item to have a `creation_time` field:
```python
item = {
'name': 'plot_name.png',
'png_href': './plot_name.png',
'pdf_href': './plot_name.pdf',
'creation_time': 1642723200 # Unix timestamp
}
```
## File Locations
### Frontend Files (Ready to use)
- `templates/gallery.html` - Updated with sort controls and data attributes
- `assets/css/view-controls.css` - Styling for sort and view controls
- `assets/css/view-override.css` - Grid layout with larger tiles
- `assets/js/sort-manager.js` - Sort functionality implementation
- `assets/js/gallery-app.js` - Integration of SortManager
- `assets/js/keyboard-manager.js` - Keyboard shortcuts for sorting
### Backend Integration
- `python/add_creation_time.py` - Example implementation for adding creation times
## Features Summary
### ✅ Completed Features
1. **Larger Grid Tiles**: ~6 plots per row instead of 8
2. **Sort Controls**: Name and time sorting with visual feedback
3. **Sort Order Toggle**: Ascending/descending with visual indicator
4. **Keyboard Shortcuts**: Quick access to all sort functions
5. **Persistence**: Settings saved across sessions
6. **Responsive Design**: Works on all screen sizes
7. **Template Integration**: Data attributes ready for backend
### 🔄 Next Steps (Backend Integration)
1. Modify your Python gallery generation code to include `creation_time`
2. Use the provided `add_creation_time_to_items()` function
3. Test with real plot files to ensure timestamps are correct
## Testing
After backend integration:
1. Navigate to a gallery with multiple plots
2. Click the sort buttons to verify functionality
3. Use keyboard shortcuts to test responsiveness
4. Check that sort order toggles correctly
5. Verify settings persist after page reload
The frontend is fully functional and will work immediately once the backend provides the `creation_time` data.
File diff suppressed because one or more lines are too long
+18 -19
View File
@@ -21,17 +21,23 @@ Example usage:
__version__ = "0.1.0"
__author__ = "K. Schmidt"
from gallery.api import generate
from gallery.builder import build_gallery, get_template
from gallery.config import (
GalleryConfig,
GallerySource,
GalleryDefaults,
GallerySource,
)
from gallery.api import generate
# Export utility functions for testing and advanced usage
from gallery.utils.stats import (
calculate_directory_stats,
format_file_size,
from gallery.utils.datetime_utils import (
datetime_from_timestamp,
strftime_filter,
)
from gallery.utils.metadata import (
load_folder_metadata,
load_metadata_file,
merge_metadata,
resolve_metadata_for_plot,
save_metadata_cache,
)
from gallery.utils.processing import (
convert_pdf_to_png,
@@ -39,18 +45,12 @@ from gallery.utils.processing import (
process_plot_files,
render_gallery_page,
)
from gallery.utils.metadata import (
load_folder_metadata,
merge_metadata,
save_metadata_cache,
load_metadata_file,
resolve_metadata_for_plot,
# Export utility functions for testing and advanced usage
from gallery.utils.stats import (
calculate_directory_stats,
format_file_size,
)
from gallery.utils.datetime_utils import (
datetime_from_timestamp,
strftime_filter,
)
from gallery.builder import build_gallery, get_template
__all__ = [
"generate",
@@ -74,4 +74,3 @@ __all__ = [
"datetime_from_timestamp",
"strftime_filter",
]
+27 -57
View File
@@ -6,19 +6,19 @@ Provides the primary entry point for programmatic gallery generation.
import shutil
from pathlib import Path
from typing import Union, List, Dict, Any
from typing import Any, Dict, List, Optional, Union, cast
from gallery.builder import build_gallery, copy_assets, get_template
from gallery.config import GalleryConfig, GallerySource
from gallery.builder import get_template, build_gallery, copy_assets
def generate(
config: Union[GalleryConfig, str, Path] = None,
web_folder: Union[str, Path] = None,
sources: List[Union[GallerySource, Dict[str, Any]]] = None,
config: Optional[Union[GalleryConfig, str, Path]] = None,
web_folder: Optional[Union[str, Path]] = None,
sources: Optional[List[Union[GallerySource, Dict[str, Any]]]] = None,
clean_first: bool = False,
verbose: bool = False,
source_to_update: GallerySource = None,
source_to_update: Optional[GallerySource] = None,
) -> bool:
"""
Generate a scientific gallery from plot sources.
@@ -79,20 +79,11 @@ def generate(
if isinstance(config, (str, Path)):
config = GalleryConfig.from_yaml(config)
elif not isinstance(config, GalleryConfig):
raise TypeError(
f"config must be GalleryConfig, str, or Path, "
f"got {type(config)}"
)
raise TypeError(f"config must be GalleryConfig, str, or Path, got {type(config)}")
else:
if web_folder is None or sources is None:
raise ValueError(
"Either config or both web_folder and sources "
"must be provided"
)
config = GalleryConfig(
web_folder=web_folder,
sources=sources or []
)
raise ValueError("Either config or both web_folder and sources must be provided")
config = GalleryConfig(web_folder=web_folder, sources=sources or [])
# Validate configuration
if not config.sources:
@@ -104,10 +95,7 @@ def generate(
web_folder_path = Path(config.web_folder)
if not _is_writable(web_folder_path):
if verbose:
print(
f"Error: Cannot write to web_folder: "
f"{config.web_folder}"
)
print(f"Error: Cannot write to web_folder: {config.web_folder}")
return False
# Create gallery root directory
@@ -127,17 +115,12 @@ def generate(
source_subdir = gallery_root / source_to_update.name
if source_subdir.exists():
if verbose:
print(
f"Updating source directory {source_to_update.name}..."
)
print(f"Updating source directory {source_to_update.name}...")
try:
shutil.rmtree(source_subdir)
except Exception as e:
if verbose:
print(
f"Warning: Could not clean source subdirectory "
f"{source_subdir}: {e}"
)
print(f"Warning: Could not clean source subdirectory {source_subdir}: {e}")
return False
try:
@@ -162,8 +145,10 @@ def generate(
return False
# Process sources
# config.sources is always List[GallerySource] after GalleryConfig.__post_init__ normalizes it.
source_subdirs = []
for source in config.sources:
source = cast(GallerySource, source)
# Skip sources not matching the update target (if specified)
if source_to_update and source.name != source_to_update.name:
# Still include them in the index if they exist
@@ -178,10 +163,7 @@ def generate(
# Validate source exists
if not source_path.exists():
if verbose:
print(
f"Warning: Source {source.path} does not exist. "
f"Skipping."
)
print(f"Warning: Source {source.path} does not exist. Skipping.")
continue
source_web_dir = gallery_root / source.name
@@ -189,47 +171,37 @@ def generate(
source_web_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
if verbose:
print(
f"Warning: Could not create directory "
f"{source_web_dir}: {e}"
)
print(f"Warning: Could not create directory {source_web_dir}: {e}")
continue
source_subdirs.append(source.name)
# Process source
if source_path.is_file() and source_path.suffix == '.pdf':
if source_path.is_file() and source_path.suffix == ".pdf":
# Single PDF file
from gallery.utils.processing import process_plot_files
item = process_plot_files(
config=config,
plot_file=source_path,
web_dir=source_web_dir,
)
from gallery.utils.processing import render_gallery_page
render_gallery_page(
config=config,
template=template,
web_dir=source_web_dir,
items=[item],
subdirs=[],
relative_path=Path(source.name)
relative_path=Path(source.name),
)
elif source_path.is_dir():
# Directory of plots
build_gallery(
config,
source_path,
source_web_dir,
template,
Path(source.name)
)
build_gallery(config, source_path, source_web_dir, template, Path(source.name))
else:
if verbose:
print(
f"Warning: Source {source.path} is neither a "
f"directory nor a PDF file. Skipping."
)
print(f"Warning: Source {source.path} is neither a directory nor a PDF file. Skipping.")
continue
if verbose:
@@ -237,15 +209,13 @@ def generate(
except Exception as e:
if verbose:
print(
f"Warning: Error processing source "
f"{source.name}: {e}"
)
print(f"Warning: Error processing source {source.name}: {e}")
continue
# Render gallery root index
try:
from gallery.utils.processing import render_gallery_page
render_gallery_page(
config=config,
template=template,
@@ -253,7 +223,7 @@ def generate(
items=[],
subdirs=source_subdirs,
relative_path=Path("."),
title="Gallery Root"
title="Gallery Root",
)
except Exception as e:
if verbose:
@@ -277,8 +247,8 @@ def generate(
def _count_gallery_plots(gallery_root: Path) -> int:
"""Recursively count plot files (PDFs and HTMLs, excluding index.html) in the gallery output."""
count = 0
for f in gallery_root.rglob('*'):
if f.is_file() and f.suffix.lower() in ('.pdf', '.html') and f.name != 'index.html':
for f in gallery_root.rglob("*"):
if f.is_file() and f.suffix.lower() in (".pdf", ".html") and f.name != "index.html":
count += 1
return count
-440
View File
@@ -1,440 +0,0 @@
/* ========================================
EXPORT FUNCTIONALITY STYLES
======================================== */
/* Selection mode styles */
.selection-mode .grid-item {
cursor: pointer;
transition: all 0.2s ease;
}
.selection-mode .grid-item:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Selection overlay */
.selection-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
display: none;
justify-content: center;
align-items: center;
border-radius: 8px;
z-index: 5;
}
.selection-mode .selection-overlay {
display: flex;
}
.selection-checkbox {
background: var(--card-background);
border: 2px solid var(--border-color);
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.2s ease;
}
.grid-item.selected .selection-checkbox {
background: var(--primary-color, #007bff);
border-color: var(--primary-color, #007bff);
color: white;
}
.checkbox-icon {
line-height: 1;
}
/* Selection counter */
.selection-counter {
position: fixed;
bottom: 100px;
right: 20px;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: var(--text-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 999;
}
/* Export button styles */
.export-btn {
background: #28a745 !important;
}
.export-btn:hover {
background: #218838 !important;
}
.export-btn:disabled {
background: #6c757d !important;
cursor: not-allowed;
}
/* Export messages */
.export-message {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 24px;
border-radius: 6px;
font-weight: 500;
z-index: 1001;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: slideIn 0.3s ease;
}
.export-message-info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.export-message-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.export-message-warning {
background: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.export-message-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Selection mode indicator */
.selection-mode::before {
content: "Selection Mode - Click plots to select them";
position: fixed;
top: 0;
left: 0;
right: 0;
background: var(--primary-color, #007bff);
color: white;
text-align: center;
padding: 8px;
font-size: 14px;
font-weight: 500;
z-index: 1000;
}
/* Adjust main content when in selection mode */
.selection-mode {
padding-top: 40px;
}
/* Export instructions overlay */
.export-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1002;
}
.export-instructions {
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 24px;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.export-instructions h3 {
margin: 0 0 16px 0;
color: var(--text-color);
}
.export-instructions p {
margin: 0 0 16px 0;
color: var(--text-color);
}
.export-data {
margin: 16px 0;
}
.export-data textarea {
width: 100%;
height: 200px;
font-family: 'Courier New', monospace;
font-size: 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 8px;
background: var(--background-color);
color: var(--text-color);
resize: vertical;
}
.export-commands {
margin: 16px 0;
padding: 12px;
background: var(--header-background);
border-radius: 4px;
border: 1px solid var(--border-color);
}
.export-command-container {
margin: 20px 0;
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.export-command {
background: var(--header-background);
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.export-command code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
line-height: 1.4;
color: var(--text-color);
word-break: break-all;
display: block;
background: none;
border: none;
padding: 0;
margin: 0;
}
.export-actions {
display: flex;
gap: 8px;
padding: 12px 16px;
background: var(--card-background);
}
.export-actions button {
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--card-background);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
}
.export-actions button:hover {
background: var(--header-background);
}
.export-actions button:last-child {
background: var(--primary-color, #007bff);
color: white;
border-color: var(--primary-color, #007bff);
}
.export-actions button:last-child:hover {
background: var(--primary-color-dark, #0056b3);
}
.copy-btn, .close-btn {
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--card-background);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
display: flex;
align-items: center;
gap: 4px;
}
.copy-btn:hover {
background: var(--primary-color, #007bff);
color: white;
border-color: var(--primary-color, #007bff);
}
.close-btn {
background: #dc3545;
color: white;
border-color: #dc3545;
margin-left: auto;
}
.close-btn:hover {
background: #c82333;
border-color: #bd2130;
}
.export-details, .export-tips {
margin: 20px 0;
padding: 16px;
border-radius: 6px;
border: 1px solid var(--border-color);
}
.export-details {
background: var(--header-background);
}
.export-tips {
background: var(--card-background);
border-color: var(--primary-color, #007bff);
border-left: 4px solid var(--primary-color, #007bff);
}
.export-details h4, .export-tips h4 {
margin: 0 0 12px 0;
color: var(--text-color);
font-size: 16px;
}
.export-details ul, .export-tips ul {
margin: 0;
padding-left: 20px;
color: var(--text-color);
}
.export-details li, .export-tips li {
margin: 8px 0;
line-height: 1.5;
}
.export-details code, .export-tips code {
background: var(--background-color);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
border: 1px solid var(--border-color);
}
.export-tips kbd {
background: var(--header-background);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 2px 6px;
font-family: inherit;
font-size: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.copy-feedback {
position: absolute;
top: 10px;
right: 10px;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
z-index: 1003;
animation: fadeInOut 2s ease-in-out;
}
.copy-feedback-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.copy-feedback-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
@keyframes fadeInOut {
0% { opacity: 0; transform: translateY(-10px); }
20% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-10px); }
}
/* Dark theme adjustments */
[data-theme="dark"] .selection-checkbox {
background: var(--card-background);
border-color: var(--border-color);
}
[data-theme="dark"] .grid-item.selected .selection-checkbox {
background: var(--primary-color, #0d6efd);
border-color: var(--primary-color, #0d6efd);
}
[data-theme="dark"] .export-message-info {
background: #0c5460;
color: #d1ecf1;
border-color: #086972;
}
[data-theme="dark"] .export-message-success {
background: #155724;
color: #d4edda;
border-color: #1e7e34;
}
[data-theme="dark"] .export-message-warning {
background: #856404;
color: #fff3cd;
border-color: #b58b14;
}
[data-theme="dark"] .export-message-error {
background: #721c24;
color: #f8d7da;
border-color: #a94442;
}
[data-theme="dark"] .copy-feedback-success {
background: #155724;
color: #d4edda;
border-color: #1e7e34;
}
[data-theme="dark"] .copy-feedback-error {
background: #721c24;
color: #f8d7da;
border-color: #a94442;
}
[data-theme="dark"] .export-tips kbd {
background: var(--background-color);
color: var(--text-color);
}
-1
View File
@@ -18,7 +18,6 @@
@import url('./metadata.css');
@import url('./metadata-section.css');
@import url('./folder-metadata.css');
@import url('./export.css');
/* View controls - must come after grid.css to override */
@import url('./view-controls.css');
View File
-444
View File
@@ -1,444 +0,0 @@
/**
* Export Manager for Gallery
* Handles exporting selected plots to merged PDF
*/
export class ExportManager {
constructor() {
this.selectedPlots = new Set();
this.maxPlots = 4;
this.init();
}
init() {
this.createExportButton();
this.bindEvents();
}
/**
* Create the export button in the floating buttons section
*/
createExportButton() {
const floatingButtons = document.querySelector('.floating-buttons');
if (!floatingButtons) return;
const exportBtn = document.createElement('button');
exportBtn.className = 'floating-btn export-btn';
exportBtn.id = 'exportBtn';
exportBtn.title = 'Export Selected Plots (Ctrl+E)';
exportBtn.innerHTML = '📄';
exportBtn.style.display = 'none'; // Hidden by default
exportBtn.onclick = () => this.exportSelectedPlots();
floatingButtons.appendChild(exportBtn);
// Add selection counter
const selectionCounter = document.createElement('div');
selectionCounter.className = 'selection-counter';
selectionCounter.id = 'selectionCounter';
selectionCounter.style.display = 'none';
selectionCounter.innerHTML = '0/4 selected';
floatingButtons.appendChild(selectionCounter);
}
/**
* Bind events for plot selection
*/
bindEvents() {
// Add selection mode toggle
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault();
this.toggleSelectionMode();
}
if (e.key === 'Escape') {
this.exitSelectionMode();
}
});
// Add selection handlers to existing plots
this.addSelectionHandlers();
}
/**
* Add selection handlers to all plot items
*/
addSelectionHandlers() {
const plotItems = document.querySelectorAll('.grid-item');
plotItems.forEach(item => this.addSelectionHandler(item));
}
/**
* Add selection handler to a single plot item
*/
addSelectionHandler(item) {
// Create selection overlay
const overlay = document.createElement('div');
overlay.className = 'selection-overlay';
overlay.innerHTML = `
<div class="selection-checkbox">
<span class="checkbox-icon"></span>
</div>
`;
item.appendChild(overlay);
// Add click handler for selection
overlay.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.togglePlotSelection(item);
});
}
/**
* Toggle selection mode
*/
toggleSelectionMode() {
const body = document.body;
const isSelectionMode = body.classList.contains('selection-mode');
if (isSelectionMode) {
this.exitSelectionMode();
} else {
this.enterSelectionMode();
}
}
/**
* Enter selection mode
*/
enterSelectionMode() {
document.body.classList.add('selection-mode');
document.getElementById('exportBtn').style.display = 'block';
document.getElementById('selectionCounter').style.display = 'block';
this.updateSelectionCounter();
}
/**
* Exit selection mode
*/
exitSelectionMode() {
const wasInSelectionMode = document.body.classList.contains('selection-mode');
document.body.classList.remove('selection-mode');
document.getElementById('exportBtn').style.display = 'none';
document.getElementById('selectionCounter').style.display = 'none';
this.clearSelection();
// Show message if user was actually in selection mode
if (wasInSelectionMode) {
this.showMessage('Exited selection mode', 'info');
}
}
/**
* Toggle plot selection
*/
togglePlotSelection(item) {
const plotName = this.getPlotName(item);
const plotPath = this.getPlotPath(item);
if (this.selectedPlots.has(plotName)) {
this.selectedPlots.delete(plotName);
item.classList.remove('selected');
item.querySelector('.checkbox-icon').textContent = '☐';
} else {
if (this.selectedPlots.size >= this.maxPlots) {
this.showMessage(`Maximum ${this.maxPlots} plots can be selected`, 'warning');
return;
}
this.selectedPlots.add(plotName);
item.classList.add('selected');
item.querySelector('.checkbox-icon').textContent = '☑';
}
this.updateSelectionCounter();
}
/**
* Get plot name from grid item
*/
getPlotName(item) {
const plotName = item.querySelector('.plot-name');
return plotName ? plotName.textContent.trim() : '';
}
/**
* Get plot PDF path from grid item
*/
getPlotPath(item) {
const link = item.querySelector('a[href$=".pdf"]');
return link ? link.href : '';
}
/**
* Update selection counter
*/
updateSelectionCounter() {
const counter = document.getElementById('selectionCounter');
if (counter) {
counter.textContent = `${this.selectedPlots.size}/${this.maxPlots} selected`;
}
const exportBtn = document.getElementById('exportBtn');
if (exportBtn) {
exportBtn.disabled = this.selectedPlots.size === 0;
exportBtn.style.opacity = this.selectedPlots.size === 0 ? '0.5' : '1';
}
}
/**
* Clear all selections
*/
clearSelection() {
this.selectedPlots.clear();
document.querySelectorAll('.grid-item.selected').forEach(item => {
item.classList.remove('selected');
const checkbox = item.querySelector('.checkbox-icon');
if (checkbox) checkbox.textContent = '☐';
});
this.updateSelectionCounter();
}
/**
* Export selected plots to merged PDF
*/
async exportSelectedPlots() {
if (this.selectedPlots.size === 0) {
this.showMessage('No plots selected', 'warning');
return;
}
const plotPaths = Array.from(this.selectedPlots).map(plotName => {
const item = Array.from(document.querySelectorAll('.grid-item'))
.find(item => this.getPlotName(item) === plotName);
return this.getPlotPath(item);
});
this.showMessage('Preparing export...', 'info');
try {
await this.createMergedPDF(plotPaths);
} catch (error) {
this.showMessage('Export failed: ' + error.message, 'error');
}
}
/**
* Create merged PDF using Python script
*/
async createMergedPDF(plotPaths) {
// Convert file:// URLs to actual paths
const actualPaths = plotPaths.map(url => {
if (url.startsWith('file://')) {
return url.substring(7); // Remove 'file://' prefix
}
return url;
});
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0];
const outputName = `merged_plots_${timestamp}.pdf`;
const payload = {
plots: actualPaths,
layout: this.calculateLayout(actualPaths.length),
output_name: outputName
};
// Generate a unique temporary filename
const tempFileName = `export_request_${Date.now()}.json`;
// Save the request to a JSON file that can be picked up by a Python script
const requestData = JSON.stringify(payload, null, 2);
// Show improved export instructions with full command
this.showExportInstructions(requestData, tempFileName);
}
/**
* Calculate optimal layout for given number of plots
*/
calculateLayout(numPlots) {
switch (numPlots) {
case 1: return { rows: 1, cols: 1 };
case 2: return { rows: 1, cols: 2 };
case 3: return { rows: 2, cols: 2 }; // 3 plots in 2x2 grid with one empty
case 4: return { rows: 2, cols: 2 };
default: return { rows: 2, cols: 2 };
}
}
/**
* Show export instructions to user
*/
showExportInstructions(requestData, tempFileName) {
const tempFilePath = `/tmp/${tempFileName}`;
const fullCommand = `echo '${requestData.replace(/'/g, "'\\''")}' > ${tempFilePath} && python export_plots.py ${tempFilePath}`;
const instructions = `
<div class="export-instructions">
<h3>🚀 Export Selected Plots</h3>
<p>Run the following command in your terminal to export the selected plots:</p>
<div class="export-command-container">
<div class="export-command">
<code id="exportCommand">${fullCommand}</code>
</div>
<div class="export-actions">
<button onclick="this.copyCommand()" class="copy-btn" title="Copy command to clipboard">
📋 Copy Command
</button>
<button onclick="this.copyJSON()" class="copy-btn" title="Copy JSON only">
📄 Copy JSON
</button>
<button onclick="this.close()" class="close-btn">
Close
</button>
</div>
</div>
<div class="export-details">
<h4>📋 Command Breakdown:</h4>
<ul>
<li><strong>Creates temporary file:</strong> <code>${tempFilePath}</code></li>
<li><strong>Runs export script:</strong> <code>python export_plots.py</code></li>
<li><strong>Output file:</strong> Will be saved in the work directory</li>
</ul>
</div>
<div class="export-tips">
<h4>💡 Tips:</h4>
<ul>
<li>The temporary JSON file will be automatically cleaned up after successful export</li>
<li>Use <kbd>Esc</kbd> to exit selection mode</li>
<li>Press <kbd>Ctrl+E</kbd> to toggle selection mode</li>
</ul>
</div>
</div>
`;
const overlay = document.createElement('div');
overlay.className = 'export-overlay';
overlay.innerHTML = instructions;
// Add methods to the overlay for button handlers
overlay.copyCommand = function() {
navigator.clipboard.writeText(fullCommand).then(() => {
this.showCopyFeedback('Command copied to clipboard!');
}).catch(() => {
this.showCopyFeedback('Failed to copy. Please select and copy manually.', 'error');
});
};
overlay.copyJSON = function() {
navigator.clipboard.writeText(requestData).then(() => {
this.showCopyFeedback('JSON copied to clipboard!');
}).catch(() => {
this.showCopyFeedback('Failed to copy. Please select and copy manually.', 'error');
});
};
overlay.close = function() {
this.remove();
};
overlay.showCopyFeedback = function(message, type = 'success') {
const feedback = document.createElement('div');
feedback.className = `copy-feedback copy-feedback-${type}`;
feedback.textContent = message;
this.appendChild(feedback);
setTimeout(() => {
if (feedback.parentNode) {
feedback.parentNode.removeChild(feedback);
}
}, 2000);
};
document.body.appendChild(overlay);
// Close on ESC key
const handleEscape = (e) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
// Close on clicking outside
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.remove();
document.removeEventListener('keydown', handleEscape);
}
});
}
/**
* Download the PDF blob
*/
downloadPDF(blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `merged_plots_${new Date().toISOString().split('T')[0]}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Show temporary message to user
*/
showMessage(text, type = 'info') {
// Remove existing message
const existing = document.querySelector('.export-message');
if (existing) existing.remove();
const message = document.createElement('div');
message.className = `export-message export-message-${type}`;
message.textContent = text;
document.body.appendChild(message);
setTimeout(() => {
if (message.parentNode) {
message.parentNode.removeChild(message);
}
}, 3000);
}
}
// Add these methods to ExportManager if not present
ExportManager.prototype.isSelectionModeActive = function() {
return document.body.classList.contains('selection-mode');
};
ExportManager.prototype.exitSelectionMode = function() {
document.body.classList.remove('selection-mode');
if (typeof this.clearSelection === 'function') {
this.clearSelection();
}
};
// Ensure a single global instance
window.exportManager = window.exportManager || new ExportManager();
// Listen for ESC key globally to exit selection mode
// (This will work even if focus is not on a plot)
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && window.exportManager && window.exportManager.isSelectionModeActive()) {
window.exportManager.exitSelectionMode();
}
});
// Attach improved export logic to export button
document.addEventListener('DOMContentLoaded', function() {
const exportBtn = document.getElementById('exportBtn');
if (exportBtn) {
exportBtn.addEventListener('click', function() {
window.exportManager.exportSelectedPlots();
});
}
});
+13 -17
View File
@@ -2,7 +2,8 @@
import shutil
from pathlib import Path
from typing import Dict, Any, Optional, Union
from typing import Any, Dict, Optional, Union
from jinja2 import Environment, FileSystemLoader, Template
from gallery.config import GalleryConfig
@@ -17,7 +18,6 @@ from gallery.utils.metadata import (
)
from gallery.utils.processing import (
process_plot_files,
needs_update,
render_gallery_page,
)
@@ -39,13 +39,14 @@ def get_template(template_dir: Optional[Union[Path, str]] = None):
if template_dir is None:
# Use package-included template
import gallery
gallery_module_path = Path(gallery.__file__).parent
template_dir = gallery_module_path / "templates"
env = Environment(loader=FileSystemLoader(str(template_dir)))
env.filters['datetime_from_timestamp'] = datetime_from_timestamp
env.filters['strftime'] = strftime_filter
env.filters["datetime_from_timestamp"] = datetime_from_timestamp
env.filters["strftime"] = strftime_filter
return env.get_template("gallery.html")
@@ -54,8 +55,8 @@ def build_gallery(
config: GalleryConfig,
source_dir: Path,
web_dir: Path,
template: Template = None,
relative_path: Path = None,
template: Optional[Template] = None,
relative_path: Optional[Path] = None,
inherited_metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""
@@ -119,7 +120,7 @@ def build_gallery(
subdir_web,
template,
subdir_relative,
current_metadata if config.inherit_from_parent else {}
current_metadata if config.inherit_from_parent else {},
)
subdir_names.append(subdir.name)
@@ -130,15 +131,11 @@ def build_gallery(
items=items,
subdirs=subdir_names,
relative_path=relative_path,
metadata=current_metadata
metadata=current_metadata,
)
def copy_assets(
config: GalleryConfig,
assets_src: Optional[Path] = None,
verbose: bool = False
) -> bool:
def copy_assets(config: GalleryConfig, assets_src: Optional[Path] = None, verbose: bool = False) -> bool:
"""
Copy assets to the web directory.
@@ -154,14 +151,13 @@ def copy_assets(
if assets_src is None:
# Use package-included assets
import gallery
gallery_module_path = Path(gallery.__file__).parent
assets_src = gallery_module_path / "assets"
if not assets_src.exists():
if verbose:
print(
f"Warning: Assets directory {assets_src} not found"
)
print(f"Warning: Assets directory {assets_src} not found")
return False
gallery_root = Path(config.web_folder) / config.plot_root
@@ -171,7 +167,7 @@ def copy_assets(
# so any change to any JS/CSS file triggers a redeploy.
sentinel_dst = assets_dst / "css" / "main.css"
newest_src_mtime = max(
(f.stat().st_mtime for f in assets_src.rglob('*') if f.is_file()),
(f.stat().st_mtime for f in assets_src.rglob("*") if f.is_file()),
default=0,
)
dst_mtime = sentinel_dst.stat().st_mtime if sentinel_dst.exists() else 0
+42 -23
View File
@@ -10,13 +10,17 @@ import argparse
import os
import sys
from pathlib import Path
from typing import List, Optional, cast
import argcomplete
from gallery import generate
from gallery.config import (
ConfigManager, GalleryConfig, GallerySource,
default_config_path, get_active_config_path, ensure_user_config,
ConfigManager,
GalleryConfig,
GallerySource,
ensure_user_config,
get_active_config_path,
)
_WELCOME = """\
@@ -58,6 +62,11 @@ Config file: {config_path}
"""
def _set_completer(action: argparse.Action, completer) -> None:
"""argcomplete reads `.completer` dynamically; argparse.Action has no such attribute."""
setattr(action, "completer", completer)
def _is_configured(config_path: Path) -> bool:
"""Return True if web_folder is set to a non-empty value."""
try:
@@ -104,13 +113,16 @@ def build_parser() -> argparse.ArgumentParser:
description="Scientific Plot Gallery Generator",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--config",
type=str,
default=None,
metavar="FILE",
help="Path to config file (default: package bundled config)",
).completer = argcomplete.completers.FilesCompleter(["yaml", "yml"])
_set_completer(
parser.add_argument(
"--config",
type=str,
default=None,
metavar="FILE",
help="Path to config file (default: package bundled config)",
),
argcomplete.completers.FilesCompleter(["yaml", "yml"]),
)
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
@@ -121,13 +133,16 @@ def build_parser() -> argparse.ArgumentParser:
description="Generate scientific gallery from plot collections",
)
gen.add_argument("--clean", action="store_true", help="Clean gallery directory before generation")
gen.add_argument(
"--source",
type=str,
default=None,
metavar="DIR",
help="Only recompute a specific source directory (name defaults to dir name)",
).completer = argcomplete.completers.DirectoriesCompleter()
_set_completer(
gen.add_argument(
"--source",
type=str,
default=None,
metavar="DIR",
help="Only recompute a specific source directory (name defaults to dir name)",
),
argcomplete.completers.DirectoriesCompleter(),
)
gen.add_argument("-v", "--verbose", action="store_true", help="Print verbose output")
# --- config -------------------------------------------------------------
@@ -146,20 +161,21 @@ def build_parser() -> argparse.ArgumentParser:
cfg_sub.add_parser("sources", help="List all configured sources")
p_get = cfg_sub.add_parser("get", help="Get a config value")
p_get.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi").completer = _config_keys
_set_completer(p_get.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi"), _config_keys)
p_set = cfg_sub.add_parser("set", help="Set a config value")
p_set.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi").completer = _config_keys
_set_completer(p_set.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi"), _config_keys)
p_set.add_argument("value", help="New value (YAML-parsed: use true/false for bools)")
p_add = cfg_sub.add_parser("add-source", help="Add a plot source")
p_add.add_argument("--name", default=None, help="Source name (default: bottom-level directory name)")
p_add.add_argument("--path", required=True, metavar="DIR", help="Path to source directory").completer = (
argcomplete.completers.DirectoriesCompleter()
_set_completer(
p_add.add_argument("--path", required=True, metavar="DIR", help="Path to source directory"),
argcomplete.completers.DirectoriesCompleter(),
)
p_rm = cfg_sub.add_parser("remove-source", help="Remove a plot source by name")
p_rm.add_argument("name", help="Source name to remove").completer = _source_names
_set_completer(p_rm.add_argument("name", help="Source name to remove"), _source_names)
return parser
@@ -174,10 +190,12 @@ def _run_generate(args: argparse.Namespace) -> int:
try:
config = GalleryConfig.from_yaml(config_path)
source_to_update = None
source_to_update: Optional[GallerySource] = None
if args.source:
source_path = Path(args.source).resolve()
matching = next((s for s in config.sources if Path(s.path).resolve() == source_path), None)
# config.sources is always List[GallerySource] after GalleryConfig.__post_init__ normalizes it.
typed_sources = cast(List[GallerySource], config.sources)
matching = next((s for s in typed_sources if Path(s.path).resolve() == source_path), None)
if matching is None:
source_to_update = GallerySource(name=source_path.name, path=source_path)
config.sources.append(source_to_update)
@@ -312,6 +330,7 @@ def main():
sys.exit(_run_install_completion())
elif args.command == "tui":
from gallery.tui import GalleryTUI
config_path = Path(args.config) if args.config else None
GalleryTUI(config_path=config_path).run()
sys.exit(0)
+19 -12
View File
@@ -8,7 +8,8 @@ defaults for gallery generation settings.
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from typing import Any, Dict, List, Optional, Union, cast
import yaml
from platformdirs import user_config_dir
@@ -31,8 +32,8 @@ def user_config_path() -> Path:
def get_active_config_path() -> Path:
"""Return the config path to use, with this precedence:
1. User config (~/.config/gallery/config.yaml) if it exists
2. Package-bundled template fallback
1. User config (~/.config/gallery/config.yaml) if it exists
2. Package-bundled template fallback
"""
ucp = user_config_path()
return ucp if ucp.exists() else default_config_path()
@@ -133,6 +134,7 @@ class ConfigManager:
@dataclass
class GalleryDefaults:
"""Default values for gallery generation."""
png_dpi: int = 400
plot_root: str = "gallery"
cache_enabled: bool = True
@@ -142,6 +144,7 @@ class GalleryDefaults:
@dataclass
class GallerySource:
"""Represents a single data source for the gallery."""
name: str
path: Union[str, Path]
@@ -157,22 +160,23 @@ class GalleryConfig:
Can be created programmatically or loaded from YAML.
"""
web_folder: Union[str, Path]
sources: List[Union[GallerySource, Dict[str, Any]]] = field(default_factory=list)
png_dpi: int = GalleryDefaults.png_dpi
plot_root: str = GalleryDefaults.plot_root
cache_enabled: bool = GalleryDefaults.cache_enabled
inherit_from_parent: bool = GalleryDefaults.inherit_from_parent
backup_folder: str = ""
def __post_init__(self):
if isinstance(self.web_folder, str):
self.web_folder = Path(self.web_folder)
normalized_sources = []
normalized_sources: List[Union[GallerySource, Dict[str, Any]]] = []
for source in self.sources:
if isinstance(source, dict):
source = GallerySource(**source)
source_dict = cast(Dict[str, Any], source)
source = GallerySource(name=source_dict["name"], path=source_dict["path"])
elif not isinstance(source, GallerySource):
raise TypeError(f"Source must be dict or GallerySource, got {type(source)}")
normalized_sources.append(source)
@@ -197,16 +201,19 @@ class GalleryConfig:
gallery_cfg = data.get("gallery", {})
sources_data = data.get("sources", [])
sources = [{"name": s["name"], "path": s["path"]} for s in sources_data]
sources: List[Union[GallerySource, Dict[str, Any]]] = [
{"name": s["name"], "path": s["path"]} for s in sources_data
]
metadata_cfg = data.get("metadata", {})
return cls(
web_folder=web_folder,
sources=sources,
png_dpi=gallery_cfg.get("png_dpi", GalleryDefaults.png_dpi),
plot_root=gallery_cfg.get("plot_root", GalleryDefaults.plot_root),
cache_enabled=data.get("metadata", {}).get("cache_enabled", GalleryDefaults.cache_enabled),
inherit_from_parent=data.get("metadata", {}).get("inherit_from_parent", GalleryDefaults.inherit_from_parent),
backup_folder=gallery_cfg.get("backup_folder", ""),
cache_enabled=metadata_cfg.get("cache_enabled", GalleryDefaults.cache_enabled),
inherit_from_parent=metadata_cfg.get("inherit_from_parent", GalleryDefaults.inherit_from_parent),
)
def to_yaml(self, yaml_file: Union[str, Path]) -> None:
@@ -220,7 +227,6 @@ class GalleryConfig:
"gallery": {
"plot_root": self.plot_root,
"png_dpi": self.png_dpi,
"backup_folder": self.backup_folder,
},
"ui": {
"max_recent_plots": 20,
@@ -231,7 +237,8 @@ class GalleryConfig:
"inherit_from_parent": self.inherit_from_parent,
"supported_formats": [".yaml", ".yml", ".json"],
},
"sources": [{"name": s.name, "path": str(s.path)} for s in self.sources],
# self.sources is always List[GallerySource] after __post_init__ normalizes it.
"sources": [{"name": s.name, "path": str(s.path)} for s in cast(List[GallerySource], self.sources)],
}
with open(yaml_path, "w") as f:
-1
View File
@@ -1,5 +1,4 @@
gallery:
backup_folder: ''
plot_root: gallery
png_dpi: 400
metadata:
+16 -19
View File
@@ -27,6 +27,7 @@ from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, ScrollableContainer, Vertical
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Button, Collapsible, Footer, Header, Input, Label, RichLog, Static
from gallery.config import ConfigManager, ensure_user_config, get_active_config_path
@@ -37,12 +38,11 @@ from gallery.config import ConfigManager, ensure_user_config, get_active_config_
# widget_id is used as the HTML-style id (#web-folder) in CSS selectors
# ---------------------------------------------------------------------------
CONFIG_FIELDS = [
("web-folder", "paths.web_folder", True),
("plot-root", "gallery.plot_root", False),
("png-dpi", "gallery.png_dpi", False),
("backup-folder", "gallery.backup_folder", False),
("cache-enabled", "metadata.cache_enabled", False),
("inherit-meta", "metadata.inherit_from_parent",False),
("web-folder", "paths.web_folder", True),
("plot-root", "gallery.plot_root", False),
("png-dpi", "gallery.png_dpi", False),
("cache-enabled", "metadata.cache_enabled", False),
("inherit-meta", "metadata.inherit_from_parent", False),
]
REQUIRED_IDS = {fid for fid, _, req in CONFIG_FIELDS if req}
@@ -249,9 +249,6 @@ class GalleryTUI(App):
with Horizontal(classes="field-row"):
yield Label("PNG DPI")
yield Input(id="png-dpi", placeholder="400")
with Horizontal(classes="field-row"):
yield Label("Backup folder")
yield Input(id="backup-folder", placeholder="leave empty to disable")
with Horizontal(classes="field-row"):
yield Label("Cache metadata")
yield Input(id="cache-enabled", placeholder="true")
@@ -269,9 +266,9 @@ class GalleryTUI(App):
# Bottom bar — always visible outside the scroll area
with Horizontal(id="footer-bar"):
yield Static("", id="dirty-indicator")
yield Button("Save Config", id="save-btn", variant="success")
yield Button("Generate", id="generate-btn", variant="primary")
yield Button("Quit", id="quit-btn", variant="error")
yield Button("Save Config", id="save-btn", variant="success")
yield Button("Generate", id="generate-btn", variant="primary")
yield Button("Quit", id="quit-btn", variant="error")
yield Footer()
@@ -280,9 +277,7 @@ class GalleryTUI(App):
# ------------------------------------------------------------------
def on_mount(self) -> None:
self._load_config_into_fields()
self.query_one("#config-path-label", Static).update(
f"Config: {self.config_path}"
)
self.query_one("#config-path-label", Static).update(f"Config: {self.config_path}")
def _make_source_row(self, name: str = "", path: str = "") -> Horizontal:
"""Return a single editable source row widget."""
@@ -368,7 +363,7 @@ class GalleryTUI(App):
self._save_config()
@on(Button.Pressed, "#quit-btn")
def action_quit(self) -> None:
async def action_quit(self) -> None:
self.exit()
@on(Button.Pressed, "#save-btn")
@@ -421,7 +416,9 @@ class GalleryTUI(App):
@on(Button.Pressed, ".source-remove-btn")
def _remove_source_row(self, event: Button.Pressed) -> None:
"""Remove the row whose button was pressed."""
event.button.parent.remove()
parent = event.button.parent
assert isinstance(parent, Widget), "source-remove button must be mounted inside a source row widget"
parent.remove()
self.dirty = True
# -- Generate --------------------------------------------------------
@@ -461,8 +458,7 @@ class GalleryTUI(App):
status.label = label
# --config is a top-level flag (before the subcommand) in the CLI parser
cmd = [sys.executable, "-m", "gallery.cli",
"--config", str(self.config_path), "generate", "--verbose"]
cmd = [sys.executable, "-m", "gallery.cli", "--config", str(self.config_path), "generate", "--verbose"]
try:
proc = subprocess.Popen(
cmd,
@@ -470,6 +466,7 @@ class GalleryTUI(App):
stderr=subprocess.STDOUT,
text=True,
)
assert proc.stdout is not None, "Popen was called with stdout=PIPE"
for line in proc.stdout:
line = line.rstrip()
if line:
-40
View File
@@ -1,40 +0,0 @@
"""Backup utilities for gallery."""
import zipfile
import datetime
from pathlib import Path
def create_backup(
web_folder: Path,
backup_folder: Path
) -> bool:
"""
Create a backup of the web folder.
Args:
web_folder: Path to the web folder to backup
backup_folder: Path to the backup directory
Returns:
True if backup was created successfully, False otherwise
"""
try:
today = datetime.date.today().strftime("%Y%m%d")
backup_name = f"backup-{today}.zip"
backup_path = backup_folder / backup_name
backup_folder.mkdir(parents=True, exist_ok=True)
if backup_path.exists():
return True
with zipfile.ZipFile(backup_path, "w", zipfile.ZIP_DEFLATED) as zipf:
for path in web_folder.rglob("*"):
if path.is_file():
arcname = path.relative_to(web_folder.parent)
zipf.write(path, arcname)
return True
except Exception as e:
print(f"Warning: Could not create backup: {e}")
return False
+15 -24
View File
@@ -13,9 +13,10 @@ Features:
"""
import json
import yaml
from pathlib import Path
from typing import Dict, Any
from typing import Any, Dict
import yaml
def load_metadata_file(metadata_path: Path) -> Dict[str, Any]:
@@ -33,15 +34,14 @@ def load_metadata_file(metadata_path: Path) -> Dict[str, Any]:
return {}
try:
with metadata_path.open('r', encoding='utf-8') as f:
with metadata_path.open("r", encoding="utf-8") as f:
suffix_lower = metadata_path.suffix.lower()
if suffix_lower == '.yaml' or suffix_lower == '.yml':
if suffix_lower == ".yaml" or suffix_lower == ".yml":
return yaml.safe_load(f) or {}
elif metadata_path.suffix.lower() == '.json':
elif metadata_path.suffix.lower() == ".json":
return json.load(f) or {}
else:
print(f"Warning: Unknown metadata file format: "
f"{metadata_path}")
print(f"Warning: Unknown metadata file format: {metadata_path}")
return {}
except (yaml.YAMLError, json.JSONDecodeError, IOError) as e:
print(f"Warning: Could not parse metadata file {metadata_path}: {e}")
@@ -59,7 +59,7 @@ def load_folder_metadata(folder_path: Path) -> Dict[str, Any]:
Dictionary containing the folder metadata
"""
# Try YAML first, then JSON for backwards compatibility
for filename in ['metadata.yaml', 'metadata.yml', 'metadata.json']:
for filename in ["metadata.yaml", "metadata.yml", "metadata.json"]:
metadata_path = folder_path / filename
if metadata_path.exists():
@@ -81,7 +81,7 @@ def get_metadata_file_path(folder_path: Path) -> str:
String path to the metadata file (existing or suggested)
"""
# Preferred order: YAML first, then JSON
preferred_files = ['metadata.yaml', 'metadata.yml', 'metadata.json']
preferred_files = ["metadata.yaml", "metadata.yml", "metadata.json"]
for filename in preferred_files:
metadata_path = folder_path / filename
@@ -89,13 +89,10 @@ def get_metadata_file_path(folder_path: Path) -> str:
return str(metadata_path)
# If no file exists, suggest metadata.yaml (preferred format)
return str(folder_path / 'metadata.yaml')
return str(folder_path / "metadata.yaml")
def merge_metadata(
parent_metadata: Dict[str, Any],
child_metadata: Dict[str, Any]
) -> Dict[str, Any]:
def merge_metadata(parent_metadata: Dict[str, Any], child_metadata: Dict[str, Any]) -> Dict[str, Any]:
"""
Merge parent and child metadata, with child values overriding parent.
@@ -111,10 +108,7 @@ def merge_metadata(
return merged
def resolve_metadata_for_plot(
plot_path: Path,
inherited_metadata: Dict[str, Any]
) -> Dict[str, Any]:
def resolve_metadata_for_plot(plot_path: Path, inherited_metadata: Dict[str, Any]) -> Dict[str, Any]:
"""
Resolve metadata for a specific plot.
@@ -131,7 +125,7 @@ def resolve_metadata_for_plot(
plot_dir = plot_path.parent
# Check for plot-specific metadata files
for suffix in ['.yaml', '.yml', '.json']:
for suffix in [".yaml", ".yml", ".json"]:
plot_metadata_path = plot_dir / f"{plot_stem}{suffix}"
if plot_metadata_path.exists():
plot_metadata = load_metadata_file(plot_metadata_path)
@@ -141,10 +135,7 @@ def resolve_metadata_for_plot(
return inherited_metadata.copy()
def save_metadata_cache(
web_dir: Path,
plot_metadata_cache: Dict[str, Dict[str, Any]]
) -> None:
def save_metadata_cache(web_dir: Path, plot_metadata_cache: Dict[str, Dict[str, Any]]) -> None:
"""
Save plot metadata cache to meta_cache.json in the web directory.
@@ -154,7 +145,7 @@ def save_metadata_cache(
"""
cache_path = web_dir / "meta_cache.json"
try:
with cache_path.open('w', encoding='utf-8') as f:
with cache_path.open("w", encoding="utf-8") as f:
json.dump(plot_metadata_cache, f, indent=2, ensure_ascii=False)
except IOError as e:
print(f"Warning: Could not save metadata cache {cache_path}: {e}")
+39 -38
View File
@@ -3,34 +3,31 @@
import shutil
import subprocess
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, Optional
from jinja2 import Template
from gallery.config import GalleryConfig
from gallery.utils.metadata import (
get_metadata_file_path,
resolve_metadata_for_plot,
)
from gallery.utils.stats import (
calculate_directory_stats,
format_file_size,
)
try:
import fitz # PyMuPDF
_PYMUPDF_AVAILABLE = True
except ImportError:
_PYMUPDF_AVAILABLE = False
_IMAGEMAGICK_AVAILABLE = shutil.which("convert") is not None
from jinja2 import Template
from gallery.utils.metadata import (
resolve_metadata_for_plot,
get_metadata_file_path,
)
from gallery.utils.stats import (
calculate_directory_stats,
format_file_size,
)
from gallery.config import GalleryConfig
def process_html_file(
html_file: Path,
web_dir: Path,
current_metadata: Dict[str, Any] = None
) -> dict:
def process_html_file(html_file: Path, web_dir: Path, current_metadata: Optional[Dict[str, Any]] = None) -> dict:
"""
Process HTML plot file, copying it to web directory.
@@ -60,15 +57,15 @@ def process_html_file(
"html_href": html_file.name,
"is_html": True,
"metadata": plot_metadata,
"creation_time": source_creation_time
"creation_time": source_creation_time,
}
def process_plot_files(
config: GalleryConfig,
plot_file: Path,
web_dir: Path,
current_metadata: Dict[str, Any] = None,
config: GalleryConfig,
plot_file: Path,
web_dir: Path,
current_metadata: Optional[Dict[str, Any]] = None,
) -> dict:
"""
Process plot files (PDF/PNG or HTML), handling conversion and copying.
@@ -82,7 +79,7 @@ def process_plot_files(
Returns:
Dictionary containing plot information
"""
if plot_file.suffix.lower() == '.html':
if plot_file.suffix.lower() == ".html":
return process_html_file(plot_file, web_dir, current_metadata)
# Handle PDF files
@@ -111,7 +108,7 @@ def process_plot_files(
"png_href": png_file.name,
"is_html": False,
"metadata": plot_metadata,
"creation_time": source_creation_time
"creation_time": source_creation_time,
}
@@ -122,8 +119,8 @@ def render_gallery_page(
items: list,
subdirs: list,
relative_path: Path,
title: str = None,
metadata: dict = None
title: Optional[str] = None,
metadata: Optional[dict] = None,
) -> None:
"""
Unified template rendering for all gallery pages.
@@ -139,8 +136,7 @@ def render_gallery_page(
metadata: Metadata dictionary (optional)
"""
if title is None:
title = "Gallery" if relative_path == Path(
".") else f"Gallery: {relative_path}"
title = "Gallery" if relative_path == Path(".") else f"Gallery: {relative_path}"
if metadata is None:
metadata = {}
@@ -151,7 +147,7 @@ def render_gallery_page(
"file_count": len(items),
"folder_count": len(subdirs),
"total_size": format_file_size(current_stats["total_size"]),
"total_size_bytes": current_stats["total_size"]
"total_size_bytes": current_stats["total_size"],
}
# Calculate relative path to assets
@@ -185,7 +181,7 @@ def render_gallery_page(
folder_metadata=metadata,
assets_path=assets_path,
source_dir=str(web_dir),
metadata_file_path=get_metadata_file_path(web_dir)
metadata_file_path=get_metadata_file_path(web_dir),
)
f.write(rendered_html)
@@ -232,13 +228,18 @@ def _convert_pdf_pymupdf(pdf_path: Path, png_path: Path, dpi: int) -> None:
def _convert_pdf_imagemagick(pdf_path: Path, png_path: Path, dpi: int) -> None:
subprocess.run([
"convert",
"-density", str(dpi),
str(pdf_path),
"-quality", "95",
str(png_path),
], check=True)
subprocess.run(
[
"convert",
"-density",
str(dpi),
str(pdf_path),
"-quality",
"95",
str(png_path),
],
check=True,
)
def needs_update(source_file: Path, target_file: Path) -> bool:
+2 -2
View File
@@ -30,9 +30,9 @@ def calculate_directory_stats(directory: Path) -> dict:
size = item.stat().st_size
stats["total_size"] += size
if item.suffix.lower() == '.pdf':
if item.suffix.lower() == ".pdf":
stats["pdf_size"] += size
elif item.suffix.lower() == '.png':
elif item.suffix.lower() == ".png":
stats["png_size"] += size
elif item.is_dir():
stats["folder_count"] += 1
+320
View File
@@ -0,0 +1,320 @@
# CLAUDE.md — `plotstyle`
Guidance for Claude Code (or any coding agent) writing or editing scripts that
use `plotstyle`. This package is a **standalone matplotlib styling toolkit**,
decoupled from the `gallery/` package in this repo — `gallery/` never imports
it. The connection between the two is a file on disk: `plotstyle` produces
PDF figures, and `gallery` (elsewhere in this repo) turns a directory of PDFs
into an HTML gallery. See the bottom of this file for that handoff.
## What it is
A KIT (Karlsruhe Institute of Technology) corporate-design matplotlib theme
plus a handful of building-block functions, so every figure produced for a
thesis chapter or a talk slide looks consistent — validated color palette,
consistent spines/ticks/grid, LaTeX text in a modern sans font, figure
titles with a parameters subtitle, and legend/panel-label helpers.
**Read `examples/plotstyle_showcase.ipynb` (repo root) for a fully rendered,
end-to-end tour** — it's the fastest way to see what every function actually
produces. Everything below is the reference; the notebook is the demo.
## Install
`plotstyle` is its own project (`plotstyle/pyproject.toml`, `uv_build`
backend, code under `plotstyle/src/plotstyle/`) and a member of this repo's
uv workspace (`[tool.uv.workspace]` in the root `pyproject.toml`) — not a
`gallery` extra.
```bash
uv sync --all-packages # this repo's uv workflow — installs gallery + plotstyle together
```
`matplotlib>=3.7` is `plotstyle`'s own direct dependency — don't add it to
`gallery`'s dependencies to support this package; `gallery` should stay
installable without ever pulling in matplotlib.
**Hard requirement: a working local LaTeX toolchain (`latex` + `dvipng`).**
`plotstyle.use()` sets `text.usetex = True` unconditionally — there is no
mathtext fallback. If a script using `plotstyle` needs to run somewhere LaTeX
isn't installed, that's a real environment gap to flag, not something to
silently work around in `plotstyle` itself (that decision was made
deliberately across several iterations of this package — don't reintroduce a
fallback without being asked).
## Quick start
```python
import numpy as np
import plotstyle as ps
ps.use() # once, before creating any figure
fig, ax = ps.new_figure(
"thesis-single",
title="Measured signal",
params={"N": 512, "sigma": 1.2, "seed": 42},
)
ax.plot(np.linspace(0, 10, 200), np.sin(np.linspace(0, 10, 200)), label="signal")
ax.set_xlabel("Time (s)")
ax.set_ylabel(r"Amplitude $A(t)$")
ps.style_legend(ax, title="Series")
ps.savefig(fig, "plots/measured_signal", formats=("pdf",))
```
## API reference
Everything is re-exported at the top level (`import plotstyle as ps`); the
submodule layout (`style.py`, `colors.py`, `figures.py`, `annotations.py`) is
an implementation detail, not part of the interface agents should reach into.
### `ps.use(cycle_linestyles: bool = False)`
Applies the theme to matplotlib's global `rcParams`. **Call this once, near
the top of the script, before creating any figure.** Sets:
- Only the bottom spine visible, colored black and heavier than default
(`axes.edgecolor`/`axes.linewidth`) — left/top/right spines off.
- Ticks on left + bottom only; x-axis gets shorter minor ticks between the
major ones (y-axis doesn't — its horizontal gridlines already mark
position). Major tick labels read as dark ink, minor tick labels lighter
grey.
- Horizontal-only, light-grey gridlines.
- Left-aligned axes titles (`axes.titlelocation: left`).
- `axes.prop_cycle` = the 9 KIT categorical colors, in fixed order. Color
only by default — pass `cycle_linestyles=True` to also cycle linestyle
(solid/dashed/dash-dot/dotted), which matters if a figure might be printed
in grayscale or viewed by someone with color-vision deficiency.
- `text.usetex = True` with Latin Modern Sans (`lmodern` + `sfmath` so
*math-mode* text is sans too, not just body text; `fontenc` T1 so plain
ASCII like `|` doesn't render as the wrong glyph under LaTeX's OT1
default).
`ps.reset()` restores matplotlib defaults (`plt.rcdefaults()`) — useful
between notebook cells or in tests, not normally needed in a script.
### `ps.new_figure(preset="thesis-single", *, title=None, params=None, **subplots_kwargs)`
Thin wrapper over `plt.subplots()`. Returns `(fig, ax)` or `(fig, axes)`
exactly like `plt.subplots()``**subplots_kwargs` (`nrows`, `ncols`,
`sharex`, ...) pass straight through.
- `preset`: one of `ps.FIGSIZES``"thesis-single"` (6×4"), `"thesis-wide"`
(8×4.5"), `"slide-16x9"` (10×5.625"), `"square"` (5×5"). Or pass an
explicit `(w, h)` tuple in inches to bypass the presets.
- `title`: sets a **left-aligned, bold figure-level title** via
`fig.suptitle`. **Prefer this over `ax.set_title()` for a single-axes
figure** — it's the recommended, consistent way to title a plot in this
codebase. Reserve `ax.set_title()` for multi-axes figures, where each
panel needs its own title and no single figure title could cover all of
them (see the multi-panel example in the notebook).
- `params`: an optional dict rendered as a smaller subtitle line under the
title: `key1: value1 | key2: value2 | ...`. Good for recording the run
parameters that produced a plot (`params={"N": 512, "seed": 42}`). Note it
is *not* colored differently from the title (see "Known limitation"
below) — only smaller.
### `ps.colorbar(mappable, ax, size="5%", pad=0.05, **kwargs)`
Use this **instead of** `fig.colorbar(im, ax=ax)` whenever `ax` has
`set_aspect("equal")` (or anything else that visually shrinks it) — plain
`fig.colorbar` sizes to the axes' nominal bounding box and ends up taller
than what's actually drawn. This appends a matching-size axes via
`mpl_toolkits.axes_grid1.make_axes_locatable` and also turns off the
colorbar's own border (`cb.outline.set_visible(False)`), which otherwise
independently picks up the bold black spine styling as a stray box around
the colorbar.
### `ps.no_spines(ax)`
Hides all four spines on `ax` (or every Axes in an array, e.g. from
`new_figure(nrows=..., ncols=...)`). `use()` keeps only the bottom spine
visible by default, since most plots have a meaningful x baseline — but
pixel/bin-indexed plots (`imshow`, `pcolormesh`, 2D histograms) don't have
one, so the themed bottom spine implies an axis origin that doesn't mean
anything there. Call this on the Axes for that kind of plot instead of
leaving the bottom spine on or hand-rolling
`ax.spines[...].set_visible(False)`:
```python
im = ax.imshow(image_data)
ps.no_spines(ax)
ps.colorbar(im, ax, label="Intensity")
```
### `ps.style_legend(ax, loc="outside right upper", frameon=False, title=None, **kwargs)`
Builds a legend from `ax`'s handles/labels but attaches it to the **figure**
(`fig.legend(...)`), so it always sits outside the axes rather than
overlapping data. Pass `title=` — strongly encouraged; omitting it prints a
`UserWarning` (the legend still renders, so this won't break a script, but
an agent generating new plots should always pass one).
### `ps.panel_label(ax, label, loc="lower right", fontweight="bold", box=True, **kwargs)`
Adds a `(a)`/`(b)`/… label for multi-panel figures. Defaults to the
bottom-right corner (nudged up from the very edge so it clears the x-axis),
colored to match `ax`'s xlabel, on a light-grey semi-transparent rounded box
with a slim solid border. Pass `box=False` for bare text. Don't hand-roll
this with `ax.text(...)` — use the helper so every panel label in a figure
(and across figures) looks the same.
### `ps.savefig(fig, path, formats=("pdf",), dpi=300)`
Writes one file per format (`path` has no extension; each format is
appended). **Default to `formats=("pdf",)`** — see "Combining with
`gallery`" below for why PDF is what you almost always want here. Creates
parent directories automatically.
### `ps.get_color(i)` / `ps.colors`
`ps.get_color(i)` indexes the 9-color categorical palette (0-based) and
raises `ValueError` past the last slot — **never** wrap/cycle back to 0
yourself past index 8; fold extra series into an "Other" bucket or facet
instead. Prefer relying on the default `prop_cycle` (i.e. just call
`ax.plot(...)` repeatedly without specifying `color=`) over calling
`get_color()` explicitly, unless you need a specific slot out of order (e.g.
matching a color used elsewhere in the same figure).
`ps.colors` also exposes, if you need direct access:
- `CATEGORICAL` — the 9 hex strings, in order.
- `sequential_cmap()` — continuous KIT-blue colormap (light tint → brand
blue) for magnitude/heatmap data.
- `diverging_cmap()` — KIT blue ↔ KIT red through a neutral grey midpoint,
for signed data. Always pass symmetric `vmin`/`vmax` around the data's true
zero when using it.
- `STATUS` — fixed `good`/`warning`/`serious`/`critical` colors. **Never**
put these in a categorical series cycle; only use them for actual
good/bad-style status encoding, always paired with a label.
- `INK` — the grey/text roles (`primary`, `secondary`, `muted`, `gridline`,
`baseline`, `surface`) the theme itself is built from.
## Best practices (for agents writing or reviewing plot scripts)
1. **Call `ps.use()` once, before any figure is created.** Don't call it
again mid-script unless deliberately toggling `cycle_linestyles` back and
forth (rare — only useful when a notebook wants to show both modes).
2. **Prefer `new_figure(title=..., params=...)` over `ax.set_title()`** for
any single-axes figure. Use `ax.set_title()` only per-panel in multi-axes
figures.
3. **Never use literal `#` or `%` in any text passed to matplotlib** (titles,
labels, legend entries, annotations) while `plotstyle` is active — usetex
is always on, and those are LaTeX special characters that will break
rendering with a `RuntimeError` from `latex`. Rephrase instead of
escaping where possible (e.g. a hex color used as a *label* should be
spelled without its `#`; a percentage should read "42 percent" or use an
escaped `\%` if you specifically need the glyph).
4. **Don't hand-style spines/ticks/grid/legend/panel-labels manually**
that's what `use()`, `style_legend()`, and `panel_label()` are for. If an
agent finds itself writing `ax.spines[...].set_visible(...)` or similar
in a script that already calls `ps.use()`, that's very likely fighting
the theme rather than working with it — stop and reconsider. The one
sanctioned exception is `ps.no_spines(ax)` on `imshow`/`pcolormesh`/2D
histogram Axes, where the themed bottom spine implies a baseline that
doesn't exist for pixel/bin data.
5. **Default `savefig(..., formats=("pdf",))`.** Only add `"png"`/`"svg"` if
there's a concrete reason (e.g. a quick raster preview outside the
gallery pipeline) — seeing `formats=("pdf", "png")` in a new script is a
signal to ask why, since the gallery already produces its own PNG
thumbnails from the PDF.
6. **This package has no test/CI dependency on a real LaTeX install being
absent** — the test suite (`tests/test_plotstyle.py`) assumes LaTeX *is*
present (this repo's dev machine has it), and exercises real rendering
rather than mocking it out. Don't add a mathtext-fallback code path to
make tests pass in a hypothetical no-LaTeX CI without being asked; that
would silently reintroduce the fallback behavior that was deliberately
removed.
7. **Known limitation, don't try to route around it:** a figure title and
its `params` subtitle can't have different colors (matplotlib's usetex
rendering tints an entire Text artist with one color; any in-source
`\color`/`\textcolor` is ignored). They're differentiated by size
(`\small`) only. If asked to make the subtitle a different color, the
real fix requires a second, independently-positioned Text artist with its
own color — flag the added complexity rather than quietly reaching for
`\textcolor` again.
## Combining with `gallery`: producing plots the gallery will display
`plotstyle` and `gallery` never share code or imports — the only connection
is that `gallery` recursively scans **source directories** (configured in
`gallery`'s `config.yaml`, see repo-root `CLAUDE.md`) for PDF/HTML files plus
`metadata.yaml`/`.yml`/`.json` files, and turns them into a static site.
`gallery` does its own PDF→PNG conversion at a configured DPI — so a
`plotstyle` script only needs to produce the PDF; **don't** also generate a
PNG "for the gallery" (that's `gallery`'s job, and a hand-made PNG would just
be redundant/inconsistent with the thumbnail `gallery` generates itself).
### End-to-end workflow
1. **Pick or create a source directory** for the project's plots — this can
be anywhere on disk, it does not need to live inside this repo (e.g.
`~/experiments/run42/plots/`). Subdirectories inside it become the
gallery's folder hierarchy.
2. **Write the plotting script using `plotstyle`**, saving into that
directory:
```python
import plotstyle as ps
ps.use()
fig, ax = ps.new_figure("thesis-single", title="Beam profile", params={"run": 42})
# ... plot ...
ps.savefig(fig, "/home/user/experiments/run42/plots/beam_profile/x_projection", formats=("pdf",))
```
3. **Add a `metadata.yaml`** in any folder of that source tree to annotate
every plot within it (and its subfolders — metadata inherits downward,
child keys override parent keys). Fields are freeform YAML — there's no
fixed schema — but a few keys get special, prominent placement in the
per-plot popup UI: `title`, `description`, `plot_type`, `experiment`.
Everything else still displays, just under "Additional Information".
```yaml
# metadata.yaml
title: "Run 42 — Beam Profile Measurements"
description: "Transverse beam profiles at IP1, measured with the wire scanner."
experiment: "Run 42"
plot_type: "beam-profile"
parameters:
beam_energy: "6.5 TeV"
bunch_intensity: "1.1e11"
tags:
- "beam-diagnostics"
- "ip1"
```
- Also accepts `.yml`/`.json` instead of `.yaml`.
- **Per-plot override**: create `<plotname>.yaml` next to
`<plotname>.pdf` (matching the PDF's stem) with just the keys to
override for that one plot — it's merged on top of the inherited
folder metadata.
- Long text values (>100 chars), lists, and nested mappings all render
sensibly in the UI automatically (truncated-with-"show more", tags,
nested key/value blocks respectively) — no special formatting needed
on the Python/YAML side.
- Metadata text values support inline LaTeX, rendered client-side via
MathJax, e.g. `formula: "$$E = mc^2$$"` or
`luminosity: "35.9 fb^{-1}"`.
4. **Register the source** in `gallery`'s `config.yaml` if it isn't already
there:
```yaml
sources:
- name: "run42"
path: "/home/user/experiments/run42/plots"
```
5. **Generate (or update) the gallery**:
```bash
gallery generate --source /home/user/experiments/run42/plots --verbose
```
Incremental: `gallery` only reconverts a PDF to PNG if the PDF is newer
than the cached PNG (with a 30s buffer — see repo-root `CLAUDE.md`), so
re-running a `plotstyle` script that overwrites the same PDF path is
exactly the intended update flow.
+40
View File
@@ -0,0 +1,40 @@
# plotstyle
A KIT (Karlsruhe Institute of Technology) corporate-design matplotlib
styling toolkit — a validated color palette, consistent spines/ticks/
gridlines, LaTeX text in a modern sans font, and a few building-block
functions (figure titles with a parameters subtitle, a same-size colorbar
helper, an outside-axes legend, panel labels) for producing figures that
look consistent across a thesis and a slide deck.
Requires a local LaTeX toolchain (`latex` + `dvipng`) — `plotstyle` always
renders text through real LaTeX, there's no fallback.
```python
import numpy as np
import plotstyle as ps
ps.use() # once, before creating any figure
fig, ax = ps.new_figure(
"thesis-single",
title="Measured signal",
params={"N": 512, "seed": 42},
)
x = np.linspace(0, 10, 200)
ax.plot(x, np.sin(x), label="signal")
ax.set_xlabel("Time (s)")
ax.set_ylabel(r"Amplitude $A(t)$")
ps.style_legend(ax, title="Series")
ps.savefig(fig, "plots/measured_signal", formats=("pdf",))
```
This package is developed as part of the
[ETPlot](https://git.larsbogner.de/lars/ETPlot) monorepo, where it is paired
with `gallery`, a static HTML gallery generator that turns directories of
plot PDFs into a browsable website — `plotstyle` has no code dependency on
`gallery`, the two only meet on disk via the PDF (and optional
`metadata.yaml`) files a `plotstyle` script writes out. See that repo's
`examples/plotstyle_showcase.ipynb` for a fully rendered tour of every
function, and `plotstyle/CLAUDE.md` for the full API reference.
+37
View File
@@ -0,0 +1,37 @@
[build-system]
requires = ["uv_build>=0.11.19,<0.12.0"]
build-backend = "uv_build"
[project]
name = "plotstyle"
version = "0.1.0"
description = "KIT corporate-design matplotlib styling toolkit for consistent scientific figures"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "MIT"}
authors = [
{name = "K. Schmidt"},
]
keywords = [
"matplotlib",
"plotting",
"scientific-computing",
"styling",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering :: Visualization",
]
dependencies = [
"matplotlib>=3.7",
]
+31
View File
@@ -0,0 +1,31 @@
"""Reusable matplotlib styling and building blocks for consistent, modern
scientific plots across presentations and thesis figures.
import plotstyle as ps
ps.use()
fig, ax = ps.new_figure("thesis-single")
ax.plot(x, y, label="A")
ps.style_legend(ax)
ps.savefig(fig, "plots/my_plot", formats=("pdf", "png"))
"""
from . import colors
from .annotations import panel_label, style_legend
from .colors import get_color
from .figures import FIGSIZES, colorbar, new_figure, no_spines, savefig
from .style import reset, use
__all__ = [
"colors",
"get_color",
"use",
"reset",
"new_figure",
"colorbar",
"no_spines",
"savefig",
"FIGSIZES",
"style_legend",
"panel_label",
]
+101
View File
@@ -0,0 +1,101 @@
"""Legend and panel-label helpers for consistent multi-panel figures."""
from __future__ import annotations
import warnings
from typing import Optional
from matplotlib.axes import Axes
from matplotlib.colors import to_rgba
from .colors import INK
_PANEL_LOCS = {
"upper left": (0.02, 0.98, "left", "top"),
"upper right": (0.98, 0.98, "right", "top"),
"lower left": (0.02, 0.08, "left", "bottom"),
"lower right": (0.98, 0.08, "right", "bottom"),
}
def style_legend(
ax: Axes,
loc: str = "outside right upper",
frameon: bool = False,
title: Optional[str] = None,
**kwargs,
):
"""Add a figure-level legend, placed outside the axes by default.
Builds on `ax`'s handles/labels (or explicit `handles=`/`labels=` kwargs)
but attaches the legend to `ax`'s figure via `fig.legend(...)`, so it sits
outside the plot area rather than overlapping the data.
A `title` is strongly encouraged: an untitled legend floating outside the
axes loses its visual link to what it's describing, e.g.
`style_legend(ax, title="Series")`. Without one, this emits a warning
rather than failing the legend still renders.
"""
if title is None:
warnings.warn(
"style_legend() called without a title — an outside legend reads better with one, "
"e.g. style_legend(ax, title='Series').",
stacklevel=2,
)
fig = ax.get_figure()
assert fig is not None, "ax must be attached to a figure"
handles = kwargs.pop("handles", None)
labels = kwargs.pop("labels", None)
if handles is None or labels is None:
handles, labels = ax.get_legend_handles_labels()
# matplotlib-stubs' `loc` Literal doesn't include the "outside ..." compound
# locations matplotlib actually supports at runtime (e.g. "outside right upper").
legend = fig.legend(handles, labels, loc=loc, frameon=frameon, title=title, **kwargs) # ty: ignore[invalid-argument-type]
if legend.get_title() is not None:
legend.get_title().set_fontweight("bold")
return legend
def panel_label(
ax: Axes,
label: str,
loc: str = "lower right",
fontweight: str = "bold",
box: bool = True,
**kwargs,
):
"""Add a panel label like "(a)" for multi-panel thesis/paper figures.
Defaults to the bottom-right corner, colored to match the axes' xlabel,
on a light-grey semi-transparent rounded box with a slim solid border.
Pass `box=False` for bare text with no box.
"""
try:
x, y, ha, va = _PANEL_LOCS[loc]
except KeyError as exc:
raise ValueError(f"Unknown panel_label loc {loc!r}. Choose from {sorted(_PANEL_LOCS)}.") from exc
kwargs.setdefault("color", ax.xaxis.label.get_color())
if box:
kwargs.setdefault(
"bbox",
dict(
boxstyle="round,pad=0.3",
facecolor=to_rgba(INK["gridline"], alpha=0.8),
edgecolor=INK["baseline"],
linewidth=0.8,
),
)
return ax.text(
x,
y,
f"({label})",
transform=ax.transAxes,
ha=ha,
va=va,
fontweight=fontweight,
**kwargs,
)
@@ -0,0 +1,80 @@
# Base rcParams for plotstyle. Loaded via plt.style.use() from style.use().
# Colors here mirror plotstyle.colors.INK (KIT black-70% gray family on white) — kept in sync by hand.
figure.facecolor: ffffff
figure.edgecolor: ffffff
figure.constrained_layout.use: True
axes.facecolor: ffffff
# axes.edgecolor/linewidth style the one visible spine (bottom — the rest are
# off below), so this is really "the bottom spine is black and heavier", not
# a general axes outline color.
axes.edgecolor: 000000
axes.linewidth: 1.25
axes.labelcolor: 6a6a6a
axes.titlecolor: 404040
axes.titleweight: bold
axes.titlelocation: left
axes.grid: True
axes.grid.axis: y
axes.axisbelow: True
axes.spines.top: False
axes.spines.right: False
axes.spines.left: False
axes.spines.bottom: True
grid.color: ececec
grid.linewidth: 0.8
grid.alpha: 1.0
xtick.color: 969696
ytick.color: 969696
# Major tick labels read as the primary ("black") ink; minor tick labels are
# muted grey. rcParams only expose one labelcolor per axis (no major/minor
# split) — the per-major/minor distinction is applied in code by
# plotstyle.figures.new_figure() via ax.tick_params(which=...). These values
# are just the fallback/default for axes that bypass new_figure().
xtick.labelcolor: 404040
ytick.labelcolor: 404040
xtick.direction: out
ytick.direction: out
xtick.bottom: True
xtick.top: False
ytick.left: True
ytick.right: False
# Minor ticks: x-axis only. The y-axis already has horizontal gridlines at
# major ticks, so y minor ticks would just add unlabeled clutter.
xtick.minor.visible: True
ytick.minor.visible: False
xtick.major.size: 6.0
xtick.minor.size: 3.0
xtick.major.width: 0.8
xtick.minor.width: 0.6
ytick.major.size: 6.0
ytick.minor.size: 3.0
ytick.major.width: 0.8
ytick.minor.width: 0.6
lines.linewidth: 2.0
lines.markersize: 6.0
lines.solid_capstyle: round
font.family: sans-serif
font.sans-serif: DejaVu Sans, Arial, Helvetica, sans-serif
font.size: 11
axes.titlesize: 13
axes.labelsize: 11
xtick.labelsize: 10
ytick.labelsize: 10
legend.fontsize: 10
legend.frameon: False
legend.handlelength: 1.6
legend.labelspacing: 0.4
legend.title_fontsize: 10
savefig.facecolor: ffffff
savefig.edgecolor: ffffff
savefig.dpi: 300
savefig.bbox: tight
savefig.pad_inches: 0.05
+102
View File
@@ -0,0 +1,102 @@
"""KIT (Karlsruhe Institute of Technology) corporate design color palette.
Hex values for KIT green, KIT blue, black 70%, and the corporate accent
colors are taken verbatim from the KIT corporate design guide
(https://kit-cd.km.kit.edu/english/341.php) do not hand-edit them without
checking that page.
The categorical *order* below is not arbitrary: it was chosen by running
every hue through the CVD-safety/contrast checks described in the `dataviz`
skill (fixed hue order, OKLab CVD separation under simulated color-vision
deficiency, a normal-vision separation floor, contrast vs. a white surface)
and keeping the ordering that clears the adjacent-pair checks. KIT yellow
(#FCE500) is the one hue that cannot pass on its own (too light on a white
surface, ~1.3:1 contrast) that is a property of the hex value itself, not
the ordering, so it is placed last and should always be paired with a
visible direct label rather than relied on as a fill alone.
"""
from __future__ import annotations
from matplotlib.colors import LinearSegmentedColormap
# Fixed-order categorical hues (KIT primary + accent colors). Order is the
# CVD-safety mechanism: never reorder, and never cycle past the last slot
# (fold extra series into "Other").
CATEGORICAL = [
"#009682", # 0 KIT green (primary)
"#DF9B1B", # 1 orange
"#4664AA", # 2 KIT blue (primary)
"#A78230", # 3 brown
"#23A1E0", # 4 cyan
"#A3107C", # 5 purple
"#8CB63C", # 6 pea green
"#A22223", # 7 red
"#FCE500", # 8 yellow — low contrast on white; always pair with a direct label
]
# Single-hue (KIT blue) sequential ramp, steps 100..700. Step 700 is the exact
# brand hex (the high/saturated end); lighter steps are tints blended toward
# white in sRGB — there is no darker-than-brand shade.
SEQUENTIAL_STEPS = [
"#e3e8f2", # 100
"#c9d2e6", # 200
"#afbcda", # 300
"#95a6ce", # 400
"#7a90c2", # 500
"#607ab6", # 600
"#4664AA", # 700 (KIT blue)
]
# Diverging KIT blue <-> KIT red, neutral gray midpoint.
DIVERGING = {
"low": "#4664AA",
"mid": "#f7f7f7",
"high": "#A22223",
}
# Fixed, reserved status scale — deliberately NOT re-themed to KIT colors:
# status is a small fixed scale with reserved meaning that must stay visually
# distinct from the categorical slots so it never impersonates a series.
# Never put these in the categorical cycle; always pair with an icon/label.
STATUS = {
"good": "#0ca30c",
"warning": "#fab219",
"serious": "#ec835a",
"critical": "#d03b3b",
}
# Chrome / ink roles, derived from KIT's specified "black 70%" (#404040,
# used by KIT for headings and continuous text) on a white surface.
INK = {
"surface": "#ffffff",
"primary": "#404040", # KIT black 70% — headings, titles, continuous text
"secondary": "#6a6a6a",
"muted": "#969696",
"gridline": "#ececec",
"baseline": "#c2c2c2",
}
def get_color(index: int) -> str:
"""Return the categorical color for series `index` (0-based).
Raises ValueError past the validated slots instead of silently wrapping
back to slot 0, which would collide two series on the same hue.
"""
if not 0 <= index < len(CATEGORICAL):
raise ValueError(
f"get_color({index}) out of range: only {len(CATEGORICAL)} validated categorical "
"colors exist. Fold extra series into an 'Other' bucket or facet instead of cycling."
)
return CATEGORICAL[index]
def sequential_cmap(name: str = "ps_sequential") -> LinearSegmentedColormap:
"""Continuous KIT-blue sequential colormap for magnitude encoding."""
return LinearSegmentedColormap.from_list(name, SEQUENTIAL_STEPS)
def diverging_cmap(name: str = "ps_diverging") -> LinearSegmentedColormap:
"""Continuous KIT blue-gray-red diverging colormap for polarity encoding."""
return LinearSegmentedColormap.from_list(name, [DIVERGING["low"], DIVERGING["mid"], DIVERGING["high"]])
+143
View File
@@ -0,0 +1,143 @@
"""Figure creation, colorbar, and saving helpers."""
from __future__ import annotations
from pathlib import Path
from typing import Mapping, Sequence, Union
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from matplotlib.colorbar import Colorbar
from matplotlib.figure import Figure
from mpl_toolkits.axes_grid1 import make_axes_locatable
from .colors import INK
FIGSIZES = {
"thesis-single": (6.0, 4.0),
"thesis-wide": (8.0, 4.5),
"slide-16x9": (10.0, 5.625),
"square": (5.0, 5.0),
}
def _style_ticks(ax: Axes) -> None:
"""Major tick labels read as primary ("black") ink; minor as muted grey.
rcParams only expose a single labelcolor per axis (no major/minor split),
so this distinction has to be applied per-Axes in code.
"""
ax.tick_params(axis="both", which="major", labelcolor=INK["primary"])
ax.tick_params(axis="both", which="minor", labelcolor=INK["muted"])
def _format_params(params: Mapping) -> str:
return " | ".join(f"{key}: {value}" for key, value in params.items())
def _set_figure_title(fig: Figure, title: Union[str, None], params: Union[Mapping, None]) -> None:
# A single Text artist gets one color under matplotlib's usetex rendering
# (dvipng rasterizes it as one greyscale mask, tinted uniformly — any
# in-source \color/\textcolor is ignored), so the subtitle is set apart
# from the title by size (\small) only, not color.
lines = []
if title is not None:
lines.append(r"\textbf{" + title + "}")
if params:
lines.append(r"{\small " + _format_params(params) + "}")
if lines:
fig.suptitle("\n".join(lines), x=0.0, ha="left")
def new_figure(
preset: Union[str, tuple] = "thesis-single",
*,
title: Union[str, None] = None,
params: Union[Mapping, None] = None,
**subplots_kwargs,
):
"""Create a figure/axes pair sized for a named preset or an explicit (w, h) tuple.
Presets (inches): thesis-single, thesis-wide, slide-16x9, square.
`title` sets a left-aligned, bold figure-level title (`fig.suptitle`)
this is preferred over an axes title even when there's a single axes, so
it stays consistent for single- and multi-panel figures alike. `params`
is an optional dict rendered as a smaller, muted subtitle line below the
title, formatted as "key1: value1 | key2: value2 | ...".
"""
if isinstance(preset, str):
try:
figsize = FIGSIZES[preset]
except KeyError as exc:
raise ValueError(
f"Unknown figure preset {preset!r}. Choose from {sorted(FIGSIZES)} or pass an (w, h) tuple."
) from exc
else:
figsize = preset
subplots_kwargs.setdefault("figsize", figsize)
fig, axes = plt.subplots(**subplots_kwargs)
for ax in [axes] if isinstance(axes, Axes) else np.ravel(axes):
_style_ticks(ax)
_set_figure_title(fig, title, params)
return fig, axes
def no_spines(ax: Union[Axes, np.ndarray]) -> None:
"""Hide every spine on `ax` — pass a single Axes or an array of them.
`use()` keeps only the bottom spine visible, since most plots have a
meaningful x baseline. Pixel/bin-indexed plots (`imshow`, `pcolormesh`,
2D histograms) don't — there's no "zero" the bottom spine anchors to
so call this on their Axes instead of leaving the themed bottom spine on
or hand-rolling `ax.spines[...].set_visible(False)`.
"""
for single_ax in [ax] if isinstance(ax, Axes) else np.ravel(ax):
for spine in single_ax.spines.values():
spine.set_visible(False)
def colorbar(mappable, ax: Axes, size: str = "5%", pad: float = 0.05, **kwargs) -> Colorbar:
"""Add a colorbar matched to `ax`'s actual on-screen size.
`fig.colorbar(mappable, ax=ax)` sizes the colorbar to the axes' nominal
bounding box, which is taller than the axes once things like
`ax.set_aspect("equal")` have visually shrunk it (e.g. a non-square
`imshow`). This appends a same-size axes via `make_axes_locatable`
instead, so the colorbar always matches what's actually drawn.
"""
divider = make_axes_locatable(ax)
cax = divider.append_axes("right", size=size, pad=pad)
fig = ax.get_figure()
assert fig is not None, "ax must be attached to a figure"
cb = fig.colorbar(mappable, cax=cax, **kwargs)
# Colorbar draws its own border (a dedicated "outline" spine) that isn't
# covered by axes.spines.{left,right,top} — without this it'd pick up
# the bold black bottom-spine color/width from the main theme as a box
# around the whole colorbar, which reads as a stray, heavier-than-intended
# edge rather than the plain baseline it's styled to be elsewhere.
cb.outline.set_visible(False)
return cb
def savefig(fig: Figure, path: Union[str, Path], formats: Sequence[str] = ("pdf",), dpi: int = 300) -> list[Path]:
"""Save `fig` to `path` once per format, creating parent directories as needed.
`path` should have no extension it's appended per format, e.g.
savefig(fig, "plots/my_plot", formats=("pdf", "png")) writes
plots/my_plot.pdf and plots/my_plot.png.
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
written = []
for fmt in formats:
out_path = path.with_suffix(f".{fmt}")
fig.savefig(out_path, format=fmt, dpi=dpi)
written.append(out_path)
return written
+64
View File
@@ -0,0 +1,64 @@
"""Apply the plotstyle rcParams theme.
Text (titles, axis labels, legends, tick labels, annotations the entirety
of every string) is always rendered through a real LaTeX toolchain, using
Latin Modern Sans as a modern sans-serif LaTeX font (including math mode, via
`sfmath` otherwise tick numbers fall back to a serif math font even with
`\\familydefault` set to sans). `fontenc`'s T1 encoding is loaded too — without
it, some plain ASCII characters (e.g. "|") render as the wrong glyph under
OT1, LaTeX's default. This requires a working local `latex`/`dvipng` install;
there is no mathtext fallback.
Note: matplotlib's usetex rendering rasterizes each Text artist as a single
greyscale glyph mask via dvipng and then tints the *whole* thing with that
artist's one `color` — any in-source `\\color`/`\\textcolor` command is
ignored. So two differently-colored spans (e.g. a black title next to a grey
subtitle) can't live in one Text object; `figures.new_figure()`'s title/params
handling only varies font size (`\\small`) between lines, not color, for
exactly this reason.
"""
from __future__ import annotations
from importlib import resources
import matplotlib.pyplot as plt
from cycler import cycler
from .colors import CATEGORICAL
_LINESTYLES = ["-", "--", "-.", ":"]
_LATEX_PREAMBLE = (
r"\usepackage[T1]{fontenc}\usepackage{amsmath}\usepackage{lmodern}\usepackage{sfmath}"
r"\renewcommand{\familydefault}{\sfdefault}"
)
def use(cycle_linestyles: bool = False) -> None:
"""Apply the plotstyle theme to matplotlib's global rcParams.
Call once at the top of a plotting script, before creating any figures.
By default `axes.prop_cycle` only cycles color (all lines solid) pass
`cycle_linestyles=True` to also cycle through a repeating linestyle
sequence, so series stay distinguishable even if color is lost
(grayscale printing, projector glare, color-vision deficiency).
"""
style_path = resources.files("plotstyle").joinpath("assets").joinpath("plotstyle.mplstyle")
plt.style.use(str(style_path))
if cycle_linestyles:
n = len(CATEGORICAL)
linestyles = (_LINESTYLES * (n // len(_LINESTYLES) + 1))[:n]
plt.rcParams["axes.prop_cycle"] = cycler(color=CATEGORICAL) + cycler(linestyle=linestyles)
else:
plt.rcParams["axes.prop_cycle"] = cycler(color=CATEGORICAL)
plt.rcParams["text.usetex"] = True
plt.rcParams["text.latex.preamble"] = _LATEX_PREAMBLE
def reset() -> None:
"""Restore matplotlib defaults (useful between tests/notebook cells)."""
plt.rcdefaults()
+19 -23
View File
@@ -1,6 +1,6 @@
[build-system]
requires = ["setuptools>=65.0", "wheel"]
build-backend = "setuptools.build_meta"
requires = ["uv_build>=0.11.19,<0.12.0"]
build-backend = "uv_build"
[project]
name = "gallery"
@@ -43,30 +43,26 @@ dependencies = [
"textual>=0.50",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"black>=22.0",
"pylint>=2.0",
"mypy>=0.900",
]
[project.scripts]
gallery = "gallery.cli:main"
[tool.setuptools]
packages = ["gallery", "gallery.utils", "gallery.config"]
package-data = {gallery = ["templates/*", "assets/css/*", "assets/js/*", "config/*"]}
include-package-data = true
[dependency-groups]
dev = [
"pytest>=7.0",
"ruff>=0.6",
"ty>=0.0.1",
"pip-audit>=2.7",
]
[tool.black]
[tool.uv.workspace]
members = ["plotstyle"]
[tool.uv.build-backend]
module-root = ""
[tool.ruff]
line-length = 120
target-version = ['py38']
target-version = "py38"
[tool.isort]
profile = "black"
line_length = 120
[tool.flake8]
max-line-length = 120
extend-ignore = ["E203", "W503"]
[tool.ruff.lint]
select = ["E", "F", "I"]
-1
View File
@@ -1,4 +1,3 @@
import sys
sys.path.append("..")
-214
View File
@@ -1,214 +0,0 @@
import zipfile
import datetime
from unittest.mock import patch
from utils import backup
def test_backup_creates_zip(tmp_path, monkeypatch):
# Setup fake web folder
web_folder = tmp_path / 'plots'
web_folder.mkdir()
(web_folder / 'file1.txt').write_text('abc')
(web_folder / 'file2.txt').write_text('def')
backup_folder = tmp_path / 'backups'
backup_folder.mkdir()
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
# Call the backup function
backup.create_backup()
# Check that backup was created
today = datetime.date.today().strftime('%Y%m%d')
backup_name = f'backup-{today}.zip'
backup_path = backup_folder / backup_name
assert backup_path.exists()
with zipfile.ZipFile(backup_path, 'r') as z:
names = z.namelist()
assert any('file1.txt' in n for n in names)
assert any('file2.txt' in n for n in names)
# Cleanup: remove the backup file after test
backup_path.unlink()
def test_backup_with_subdirectories(tmp_path, monkeypatch):
# Setup fake web folder with subdirectories
web_folder = tmp_path / 'plots'
web_folder.mkdir()
(web_folder / 'file1.txt').write_text('content1')
subdir = web_folder / 'subdir'
subdir.mkdir()
(subdir / 'file2.txt').write_text('content2')
nested_subdir = subdir / 'nested'
nested_subdir.mkdir()
(nested_subdir / 'file3.txt').write_text('content3')
backup_folder = tmp_path / 'backups'
backup_folder.mkdir()
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
# Call the backup function
backup.create_backup()
# Check that backup was created with all files
today = datetime.date.today().strftime('%Y%m%d')
backup_name = f'backup-{today}.zip'
backup_path = backup_folder / backup_name
assert backup_path.exists()
with zipfile.ZipFile(backup_path, 'r') as z:
names = z.namelist()
assert any('file1.txt' in n for n in names)
assert any('file2.txt' in n for n in names)
assert any('file3.txt' in n for n in names)
# Cleanup
backup_path.unlink()
def test_backup_existing_file(tmp_path, monkeypatch, capsys):
# Setup fake web folder
web_folder = tmp_path / 'plots'
web_folder.mkdir()
(web_folder / 'file1.txt').write_text('abc')
backup_folder = tmp_path / 'backups'
backup_folder.mkdir()
# Create existing backup file
today = datetime.date.today().strftime('%Y%m%d')
backup_name = f'backup-{today}.zip'
backup_path = backup_folder / backup_name
backup_path.write_text('existing backup')
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
# Call the backup function
backup.create_backup()
# Check that message about existing backup was printed
captured = capsys.readouterr()
assert f"Backup already exists: {backup_path}" in captured.out
# Cleanup
backup_path.unlink()
def test_backup_empty_folder(tmp_path, monkeypatch):
# Setup empty web folder
web_folder = tmp_path / 'plots'
web_folder.mkdir()
backup_folder = tmp_path / 'backups'
backup_folder.mkdir()
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
# Call the backup function
backup.create_backup()
# Check that backup was created (empty)
today = datetime.date.today().strftime('%Y%m%d')
backup_name = f'backup-{today}.zip'
backup_path = backup_folder / backup_name
assert backup_path.exists()
with zipfile.ZipFile(backup_path, 'r') as z:
assert len(z.namelist()) == 0
# Cleanup
backup_path.unlink()
def test_backup_nonexistent_web_folder(tmp_path, monkeypatch):
# Setup nonexistent web folder
web_folder = tmp_path / 'nonexistent_plots'
backup_folder = tmp_path / 'backups'
backup_folder.mkdir()
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
# Call the backup function
backup.create_backup()
# Check that backup was created (empty since source doesn't exist)
today = datetime.date.today().strftime('%Y%m%d')
backup_name = f'backup-{today}.zip'
backup_path = backup_folder / backup_name
assert backup_path.exists()
with zipfile.ZipFile(backup_path, 'r') as z:
assert len(z.namelist()) == 0
# Cleanup
backup_path.unlink()
@patch('datetime.date')
def test_backup_with_custom_date(mock_date, tmp_path, monkeypatch):
# Mock date to return a specific date
mock_date.today.return_value.strftime.return_value = "20230908"
# Setup fake web folder
web_folder = tmp_path / 'plots'
web_folder.mkdir()
(web_folder / 'file1.txt').write_text('test')
backup_folder = tmp_path / 'backups'
backup_folder.mkdir()
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
# Call the backup function
backup.create_backup()
# Check that backup was created with custom date
backup_path = backup_folder / "backup-20230908.zip"
assert backup_path.exists()
# Cleanup
backup_path.unlink()
def test_backup_folder_creation(tmp_path, monkeypatch):
# Setup fake web folder
web_folder = tmp_path / 'plots'
web_folder.mkdir()
(web_folder / 'file1.txt').write_text('test')
# Don't create backup folder - let function create it
backup_folder = tmp_path / 'new_backups'
# Patch the module variables
monkeypatch.setattr(backup, 'WEB_FOLDER', web_folder)
monkeypatch.setattr(backup, 'BACKUP_FOLDER', backup_folder)
# Call the backup function
backup.create_backup()
# Check that backup folder was created
assert backup_folder.exists()
assert backup_folder.is_dir()
# Check that backup file was created
today = datetime.date.today().strftime('%Y%m%d')
backup_name = f'backup-{today}.zip'
backup_path = backup_folder / backup_name
assert backup_path.exists()
+151 -163
View File
@@ -1,195 +1,183 @@
from pathlib import Path
import tempfile
import pytest
import yaml
from utils import config
from gallery.config import ConfigManager, GalleryConfig, GalleryDefaults, GallerySource
def test_path_config():
pc = config.PathConfig(work_dir='/tmp', web_folder='/web')
assert pc.work_dir == '/tmp'
assert pc.web_folder == '/web'
def test_gallery_source_path_conversion():
source = GallerySource(name="test", path="/test/path")
assert source.name == "test"
assert source.path == Path("/test/path")
def test_gallery_config():
gc = config.GalleryConfig(
plot_root='plots', png_dpi=150, backup_folder='backups')
assert gc.plot_root == 'plots'
assert gc.png_dpi == 150
assert gc.backup_folder == 'backups'
def test_gallery_defaults():
defaults = GalleryDefaults()
assert defaults.png_dpi == 400
assert defaults.plot_root == "gallery"
assert defaults.cache_enabled is True
assert defaults.inherit_from_parent is True
def test_ui_config():
ui = config.UIConfig(max_recent_plots=10, search_debounce_ms=200)
assert ui.max_recent_plots == 10
assert ui.search_debounce_ms == 200
def test_gallery_config_defaults():
cfg = GalleryConfig(web_folder="/web")
assert cfg.web_folder == Path("/web")
assert cfg.sources == []
assert cfg.png_dpi == GalleryDefaults.png_dpi
assert cfg.plot_root == GalleryDefaults.plot_root
assert cfg.cache_enabled == GalleryDefaults.cache_enabled
assert cfg.inherit_from_parent == GalleryDefaults.inherit_from_parent
def test_metadata_config_defaults():
mc = config.MetadataConfig()
assert mc.cache_enabled is True
assert mc.inherit_from_parent is True
assert mc.supported_formats == ['.yaml', '.yml', '.json']
def test_gallery_config_sources_from_dicts():
cfg = GalleryConfig(web_folder="/web", sources=[{"name": "s1", "path": "/p1"}])
assert len(cfg.sources) == 1
assert isinstance(cfg.sources[0], GallerySource)
assert cfg.sources[0].name == "s1"
assert cfg.sources[0].path == Path("/p1")
def test_metadata_config_custom():
mc = config.MetadataConfig(
cache_enabled=False,
inherit_from_parent=False,
supported_formats=['.yaml']
)
assert mc.cache_enabled is False
assert mc.inherit_from_parent is False
assert mc.supported_formats == ['.yaml']
def test_gallery_config_sources_invalid_type():
with pytest.raises(TypeError):
GalleryConfig(web_folder="/web", sources=[123])
def test_gallery_item():
item = config.GalleryItem(name="test", path=Path("/test/path"))
assert item.name == "test"
assert item.path == Path("/test/path")
def test_gallery_config_from_yaml(tmp_path):
yaml_content = {
"paths": {"web_folder": "/test/web"},
"gallery": {"plot_root": "test_plots", "png_dpi": 200},
"metadata": {"cache_enabled": False, "inherit_from_parent": False},
"sources": [
{"name": "source1", "path": "/path1"},
{"name": "source2", "path": "/path2"},
],
}
yaml_file = tmp_path / "test_config.yaml"
with yaml_file.open("w") as f:
yaml.dump(yaml_content, f)
cfg = GalleryConfig.from_yaml(yaml_file)
assert cfg.web_folder == Path("/test/web")
assert cfg.plot_root == "test_plots"
assert cfg.png_dpi == 200
assert cfg.cache_enabled is False
assert cfg.inherit_from_parent is False
assert len(cfg.sources) == 2
assert cfg.sources[0].name == "source1"
assert cfg.sources[0].path == Path("/path1")
def test_config_creation():
paths = config.PathConfig(work_dir="/work", web_folder="/web")
gallery = config.GalleryConfig(
plot_root="plots", png_dpi=300, backup_folder="backups")
ui = config.UIConfig(max_recent_plots=5, search_debounce_ms=100)
metadata = config.MetadataConfig()
def test_gallery_config_from_yaml_missing_file():
with pytest.raises(FileNotFoundError):
GalleryConfig.from_yaml("/nonexistent/file.yaml")
cfg = config.Config(
paths=paths,
gallery=gallery,
ui=ui,
metadata=metadata
)
assert cfg.paths == paths
assert cfg.gallery == gallery
assert cfg.ui == ui
assert cfg.metadata == metadata
def test_gallery_config_from_yaml_missing_web_folder(tmp_path):
yaml_file = tmp_path / "no_web_folder.yaml"
yaml_file.write_text(yaml.dump({"gallery": {"plot_root": "plots"}}))
with pytest.raises(ValueError):
GalleryConfig.from_yaml(yaml_file)
def test_gallery_config_from_yaml_partial_data(tmp_path):
yaml_content = {"paths": {"web_folder": "/min_web"}}
yaml_file = tmp_path / "minimal_config.yaml"
yaml_file.write_text(yaml.dump(yaml_content))
cfg = GalleryConfig.from_yaml(yaml_file)
assert cfg.web_folder == Path("/min_web")
assert cfg.png_dpi == GalleryDefaults.png_dpi
assert cfg.plot_root == GalleryDefaults.plot_root
assert cfg.cache_enabled is True
assert cfg.sources == []
def test_config_backward_compatibility_properties():
paths = config.PathConfig(work_dir="/work", web_folder="/web")
gallery = config.GalleryConfig(
plot_root="plots", png_dpi=300, backup_folder="backups")
ui = config.UIConfig(max_recent_plots=5, search_debounce_ms=100)
metadata = config.MetadataConfig()
cfg = config.Config(
paths=paths,
gallery=gallery,
ui=ui,
metadata=metadata
def test_gallery_config_to_yaml_round_trip(tmp_path):
cfg = GalleryConfig(
web_folder="/web",
sources=[{"name": "test", "path": "/test"}],
plot_root="plots",
png_dpi=300,
)
assert cfg.web_folder == "/web"
assert cfg.png_dpi == 300
assert cfg.plot_root == "plots"
assert cfg.backup_folder == "backups"
def test_config_from_yaml(tmp_path):
yaml_content = {
'paths': {
'work_dir': '/test/work',
'web_folder': '/test/web'
},
'gallery': {
'plot_root': 'test_plots',
'png_dpi': 200,
'backup_folder': 'test_backups'
},
'ui': {
'max_recent_plots': 15,
'search_debounce_ms': 300
},
'metadata': {
'cache_enabled': False,
'inherit_from_parent': False
},
'sources': [
{'name': 'source1', 'path': '/path1'},
{'name': 'source2', 'path': '/path2'}
]
}
yaml_file = tmp_path / 'test_config.yaml'
with yaml_file.open('w') as f:
yaml.dump(yaml_content, f)
cfg = config.Config.from_yaml(str(yaml_file))
assert cfg.paths.work_dir == '/test/work'
assert cfg.paths.web_folder == '/test/web'
assert cfg.gallery.plot_root == 'test_plots'
assert cfg.gallery.png_dpi == 200
assert cfg.ui.max_recent_plots == 15
assert cfg.metadata.cache_enabled is False
assert len(cfg.sources) == 2
assert cfg.sources[0].name == 'source1'
assert cfg.sources[0].path == Path('/path1')
def test_config_from_yaml_missing_file():
with pytest.raises(FileNotFoundError):
config.Config.from_yaml('/nonexistent/file.yaml')
def test_config_from_yaml_malformed():
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml',
delete=False) as f:
f.write('invalid: yaml: content: [')
f.flush()
with pytest.raises(yaml.YAMLError):
config.Config.from_yaml(f.name)
def test_config_to_yaml(tmp_path):
paths = config.PathConfig(work_dir="/work", web_folder="/web")
gallery = config.GalleryConfig(
plot_root="plots", png_dpi=300, backup_folder="backups")
ui = config.UIConfig(max_recent_plots=5, search_debounce_ms=100)
metadata = config.MetadataConfig()
sources = [config.GalleryItem(name="test", path=Path("/test"))]
cfg = config.Config(
paths=paths,
gallery=gallery,
ui=ui,
metadata=metadata,
sources=sources
)
yaml_file = tmp_path / 'output_config.yaml'
cfg.to_yaml(str(yaml_file))
yaml_file = tmp_path / "output_config.yaml"
cfg.to_yaml(yaml_file)
assert yaml_file.exists()
# Just verify the file contains expected content (no Path parsing)
content = yaml_file.read_text()
assert 'work_dir: /work' in content
assert 'png_dpi: 300' in content
assert 'name: test' in content
reloaded = GalleryConfig.from_yaml(yaml_file)
assert reloaded.web_folder == cfg.web_folder
assert reloaded.plot_root == cfg.plot_root
assert reloaded.png_dpi == cfg.png_dpi
assert reloaded.sources[0].name == "test"
def test_config_from_yaml_partial_data(tmp_path):
# Test with minimal YAML data
yaml_content = {
'paths': {'work_dir': '/min', 'web_folder': '/min_web'},
'gallery': {'plot_root': 'min_plots', 'png_dpi': 100,
'backup_folder': 'min_backup'},
'ui': {'max_recent_plots': 3, 'search_debounce_ms': 50}
}
# ---------------------------------------------------------------------------
# ConfigManager
# ---------------------------------------------------------------------------
yaml_file = tmp_path / 'minimal_config.yaml'
with yaml_file.open('w') as f:
yaml.dump(yaml_content, f)
cfg = config.Config.from_yaml(str(yaml_file))
@pytest.fixture
def config_manager(tmp_path):
path = tmp_path / "config.yaml"
path.write_text(
yaml.dump(
{
"paths": {"web_folder": "/web"},
"gallery": {"plot_root": "gallery", "png_dpi": 400},
"sources": [{"name": "existing", "path": "/existing"}],
}
)
)
return ConfigManager(path)
# Should use defaults for metadata and empty sources
assert cfg.metadata.cache_enabled is True # default
assert cfg.sources == [] # default empty list
def test_config_manager_get(config_manager):
assert config_manager.get("gallery.png_dpi") == 400
def test_config_manager_get_missing_key(config_manager):
with pytest.raises(KeyError):
config_manager.get("gallery.nonexistent")
def test_config_manager_set(config_manager):
config_manager.set("gallery.png_dpi", "600")
assert config_manager.get("gallery.png_dpi") == 600
def test_config_manager_list_all(config_manager):
data = config_manager.list_all()
assert data["paths"]["web_folder"] == "/web"
def test_config_manager_add_source(config_manager):
config_manager.add_source("new_source", "/new/path")
sources = config_manager.list_sources()
assert {"name": "new_source", "path": "/new/path"} in sources
def test_config_manager_add_source_duplicate(config_manager):
with pytest.raises(ValueError):
config_manager.add_source("existing", "/other/path")
def test_config_manager_remove_source(config_manager):
config_manager.remove_source("existing")
assert config_manager.list_sources() == []
def test_config_manager_remove_source_not_found(config_manager):
with pytest.raises(KeyError):
config_manager.remove_source("nonexistent")
def test_config_manager_list_sources(config_manager):
sources = config_manager.list_sources()
assert sources == [{"name": "existing", "path": "/existing"}]
-138
View File
@@ -1,138 +0,0 @@
import pytest
import tempfile
import time
import os
from pathlib import Path
import subprocess
import sys
def test_python_version():
"""Test that Python 3.9+ is available."""
version = sys.version_info
assert version.major >= 3
assert version.minor >= 9
def test_required_modules():
"""Test that required Python modules are installed."""
try:
import jinja2 # noqa: F401
import yaml # noqa: F401
except ImportError as e:
pytest.fail(f"Required module not found: {e}")
def test_imagemagick_available():
"""Test that ImageMagick is installed and accessible."""
try:
result = subprocess.run(['convert', '-version'], capture_output=True, text=True, timeout=10)
assert result.returncode == 0
assert 'ImageMagick' in result.stdout
except (subprocess.TimeoutExpired, FileNotFoundError):
pytest.fail("ImageMagick not available or not working")
def test_format_file_size():
# Add current directory to path instead of /src
sys.path.insert(0, '.')
from gallery import format_file_size
assert format_file_size(0) == "0 B"
assert format_file_size(1024) == "1.0 KB"
assert format_file_size(1048576) == "1.0 MB"
assert format_file_size(1073741824) == "1.0 GB"
def test_needs_update():
sys.path.insert(0, '.')
from gallery import needs_update
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
source = temp_path / "source.txt"
target = temp_path / "target.txt"
source.write_text("test")
assert needs_update(source, target)
target.write_text("test")
time.sleep(0.1)
os.utime(target, (time.time(), time.time()))
assert not needs_update(source, target)
def test_metadata_loading():
sys.path.insert(0, '.')
from utils.metadata import load_metadata_file
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
yaml_file = temp_path / "test.yaml"
yaml_content = "title: Test\nauthor: Container Test\n"
yaml_file.write_text(yaml_content)
metadata = load_metadata_file(yaml_file)
assert metadata['title'] == 'Test'
assert metadata['author'] == 'Container Test'
def test_metadata_inheritance():
sys.path.insert(0, '.')
from utils.metadata import merge_metadata
parent = {'project': 'Test', 'version': '1.0'}
child = {'experiment': 'A', 'version': '1.1'}
merged = merge_metadata(parent, child)
assert merged['project'] == 'Test'
assert merged['experiment'] == 'A'
assert merged['version'] == '1.1'
def create_mock_pdf(path: Path):
path.write_text("%PDF-1.4\nMock PDF for testing")
def test_pdf_conversion(tmpdir):
sys.path.insert(0, '/src')
from gallery import convert_pdf_to_png, GalleryConfig
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
pdf_path = temp_path / "test.pdf"
create_mock_pdf(pdf_path)
try:
convert_pdf_to_png(pdf_path, GalleryConfig(tmpdir))
png_path = pdf_path.with_suffix('.png')
assert png_path.exists()
except subprocess.CalledProcessError:
pytest.skip("Mock PDF not processable by ImageMagick")
def create_test_structure(source_dir):
pdf_path = source_dir / "test_plot.pdf"
pdf_path.write_text("%PDF-1.4\nTest plot content")
metadata_path = source_dir / "metadata.yaml"
metadata_path.write_text("title: Container Test\nauthor: CI Pipeline\n")
def test_build_gallery(tmpdir):
sys.path.insert(0, '.')
from gallery import build_gallery, GalleryConfig
from unittest.mock import patch
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
source_dir = temp_path / "source"
web_dir = temp_path / "web"
source_dir.mkdir()
web_dir.mkdir()
create_test_structure(source_dir)
# Mock the PDF conversion and create the expected PNG file
def mock_convert_pdf_to_png(pdf_path):
png_path = pdf_path.with_suffix('.png')
png_path.write_text("Mock PNG content")
with patch('gallery.convert_pdf_to_png', side_effect=mock_convert_pdf_to_png):
try:
build_gallery(GalleryConfig(tmpdir), source_dir=source_dir, web_dir=web_dir)
html_file = web_dir / "index.html"
assert html_file.exists()
pdf_file = web_dir / "test_plot.pdf"
assert pdf_file.exists()
png_file = web_dir / "test_plot.png"
assert png_file.exists()
except Exception as e:
pytest.skip(f"Gallery generation failed: {e}")
+47 -58
View File
@@ -1,17 +1,17 @@
import os
from datetime import datetime
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from unittest.mock import patch
from gallery import (
convert_pdf_to_png,
needs_update,
build_gallery,
calculate_directory_stats,
format_file_size,
convert_pdf_to_png,
datetime_from_timestamp,
strftime_filter
format_file_size,
needs_update,
strftime_filter,
)
from datetime import datetime
def test_format_file_size():
@@ -46,6 +46,7 @@ def test_needs_update_target_newer(tmp_path):
# Make target newer by modifying its timestamp
import time
time.sleep(0.1)
target.touch()
@@ -58,6 +59,7 @@ def test_needs_update_source_newer(tmp_path):
target.write_text("test")
import time
time.sleep(0.1)
source.write_text("test")
@@ -69,28 +71,24 @@ def test_needs_update_source_newer(tmp_path):
assert needs_update(source, target) is True
@patch('subprocess.run')
def test_convert_pdf_to_png_success(mock_run, tmp_path):
@patch("gallery.utils.processing._convert_pdf_pymupdf")
def test_convert_pdf_to_png_success(mock_convert, tmp_path):
from gallery import GalleryConfig
pdf_path = tmp_path / "test.pdf"
png_path = tmp_path / "test.png"
pdf_path.write_text("fake pdf")
mock_run.return_value = MagicMock(returncode=0)
config = GalleryConfig(tmp_path)
convert_pdf_to_png(pdf_path, config)
convert_pdf_to_png(pdf_path, GalleryConfig(tmp_path))
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0]
assert call_args[0] == "convert"
assert str(pdf_path) in call_args
assert str(png_path) in call_args
mock_convert.assert_called_once_with(pdf_path, png_path, config.png_dpi)
@patch('subprocess.run')
def test_convert_pdf_to_png_already_exists_newer(mock_run, tmp_path):
@patch("gallery.utils.processing._convert_pdf_pymupdf")
def test_convert_pdf_to_png_already_exists_newer(mock_convert, tmp_path):
from gallery import GalleryConfig
pdf_path = tmp_path / "test.pdf"
png_path = tmp_path / "test.png"
@@ -104,27 +102,27 @@ def test_convert_pdf_to_png_already_exists_newer(mock_run, tmp_path):
convert_pdf_to_png(pdf_path, GalleryConfig(tmp_path))
# Should not call subprocess since PNG is newer
mock_run.assert_not_called()
# Should not convert since PNG is newer
mock_convert.assert_not_called()
@patch('subprocess.run')
def test_convert_pdf_to_png_pdf_newer(mock_run, tmp_path):
@patch("gallery.utils.processing._convert_pdf_pymupdf")
def test_convert_pdf_to_png_pdf_newer(mock_convert, tmp_path):
from gallery import GalleryConfig
pdf_path = tmp_path / "test.pdf"
png_path = tmp_path / "test.png"
png_path.write_text("fake png")
import time
time.sleep(0.1)
pdf_path.write_text("fake pdf")
mock_run.return_value = MagicMock(returncode=0)
convert_pdf_to_png(pdf_path, GalleryConfig(tmp_path))
# Should call subprocess since PDF is newer
mock_run.assert_called_once()
# Should convert since PDF is newer
mock_convert.assert_called_once()
def test_calculate_directory_stats_empty(tmp_path):
@@ -181,24 +179,17 @@ def test_strftime_filter():
assert formatted == "2023-09-08 14:30"
@patch('gallery.builder.render_gallery_page')
@patch('gallery.utils.metadata.save_metadata_cache')
@patch('gallery.utils.metadata.resolve_metadata_for_plot')
@patch('gallery.utils.metadata.merge_metadata')
@patch('gallery.utils.metadata.load_folder_metadata')
@patch('gallery.utils.processing.convert_pdf_to_png')
@patch('shutil.copy2')
@patch("gallery.builder.save_metadata_cache")
@patch("gallery.utils.processing.resolve_metadata_for_plot")
@patch("gallery.builder.merge_metadata")
@patch("gallery.builder.load_folder_metadata")
@patch("gallery.utils.processing.convert_pdf_to_png")
@patch("shutil.copy2")
def test_build_gallery_basic(
mock_copy,
mock_convert,
mock_load_folder,
mock_merge,
mock_resolve,
mock_save_cache,
mock_template,
tmp_path
mock_copy, mock_convert, mock_load_folder, mock_merge, mock_resolve, mock_save_cache, tmp_path
):
from gallery import GalleryConfig
from gallery import GalleryConfig, get_template
source_dir = tmp_path / "source"
web_dir = tmp_path / "web"
source_dir.mkdir()
@@ -214,29 +205,24 @@ def test_build_gallery_basic(
mock_load_folder.return_value = {"folder": "metadata"}
mock_merge.return_value = {"merged": "metadata"}
mock_resolve.return_value = {"plot": "metadata"}
mock_template.render.return_value = "<html>test</html>"
build_gallery(GalleryConfig(tmp_path), source_dir, web_dir)
config = GalleryConfig(tmp_path)
build_gallery(config, source_dir, web_dir, template=get_template())
# Verify mocks were called
mock_convert.assert_called_once_with(pdf_file)
mock_convert.assert_called_once_with(pdf_file, config=config)
mock_copy.assert_called() # Should be called for PDF
mock_save_cache.assert_called_once()
# Check HTML file was created
# Check HTML file was created by the real render_gallery_page
html_file = web_dir / "index.html"
assert html_file.exists()
@patch('gallery.builder.render_gallery_page')
@patch('gallery.utils.metadata.save_metadata_cache')
@patch('gallery.utils.metadata.load_folder_metadata')
def test_build_gallery_with_subdirs(
mock_load_folder,
mock_save_cache,
mock_template,
tmp_path
):
@patch("gallery.builder.render_gallery_page")
@patch("gallery.builder.save_metadata_cache")
@patch("gallery.builder.load_folder_metadata")
def test_build_gallery_with_subdirs(mock_load_folder, mock_save_cache, mock_template, tmp_path):
source_dir = tmp_path / "source"
web_dir = tmp_path / "web"
source_dir.mkdir()
@@ -250,6 +236,7 @@ def test_build_gallery_with_subdirs(
mock_template.render.return_value = "<html>test</html>"
from gallery import GalleryConfig
build_gallery(config=GalleryConfig(tmp_path), source_dir=source_dir, web_dir=web_dir)
# Check subdirectory was created in web
@@ -258,8 +245,8 @@ def test_build_gallery_with_subdirs(
assert web_subdir.is_dir()
@patch('gallery.utils.processing.needs_update')
@patch('shutil.copy2')
@patch("gallery.utils.processing.needs_update")
@patch("shutil.copy2")
def test_build_gallery_skip_up_to_date(mock_copy, mock_needs_update, tmp_path):
source_dir = tmp_path / "source"
web_dir = tmp_path / "web"
@@ -282,8 +269,10 @@ def test_build_gallery_skip_up_to_date(mock_copy, mock_needs_update, tmp_path):
mock_needs_update.return_value = False
from gallery import get_template
get_template()
from gallery import GalleryConfig
build_gallery(GalleryConfig(tmp_path), source_dir, web_dir)
# copy2 should not be called since files are up to date
+49 -52
View File
@@ -1,79 +1,81 @@
import json
import pytest
from utils import metadata
import yaml
from gallery.utils import metadata
def test_load_metadata_file_yaml(tmp_path):
data = {'a': 1, 'b': 'test'}
yaml_path = tmp_path / 'meta.yaml'
data = {"a": 1, "b": "test"}
yaml_path = tmp_path / "meta.yaml"
yaml_path.write_text(yaml.dump(data))
result = metadata.load_metadata_file(yaml_path)
assert result == data
def test_load_metadata_file_json(tmp_path):
data = {'x': 42, 'y': 'hello'}
json_path = tmp_path / 'meta.json'
data = {"x": 42, "y": "hello"}
json_path = tmp_path / "meta.json"
json_path.write_text(json.dumps(data))
result = metadata.load_metadata_file(json_path)
assert result == data
def test_load_metadata_file_missing(tmp_path):
missing_path = tmp_path / 'nope.yaml'
missing_path = tmp_path / "nope.yaml"
result = metadata.load_metadata_file(missing_path)
assert result == {}
def test_load_metadata_file_yml_extension(tmp_path):
data = {'test': 'yml_format'}
yml_path = tmp_path / 'meta.yml'
data = {"test": "yml_format"}
yml_path = tmp_path / "meta.yml"
yml_path.write_text(yaml.dump(data))
result = metadata.load_metadata_file(yml_path)
assert result == data
def test_load_metadata_file_unknown_format(tmp_path):
txt_path = tmp_path / 'meta.txt'
txt_path.write_text('some text')
txt_path = tmp_path / "meta.txt"
txt_path.write_text("some text")
result = metadata.load_metadata_file(txt_path)
assert result == {}
def test_load_metadata_file_malformed_yaml(tmp_path):
yaml_path = tmp_path / 'bad.yaml'
yaml_path.write_text('invalid: yaml: content: [')
yaml_path = tmp_path / "bad.yaml"
yaml_path.write_text("invalid: yaml: content: [")
with pytest.raises(yaml.YAMLError):
metadata.load_metadata_file(yaml_path)
def test_load_metadata_file_malformed_json(tmp_path):
json_path = tmp_path / 'bad.json'
json_path = tmp_path / "bad.json"
json_path.write_text('{"invalid": json}')
with pytest.raises(json.JSONDecodeError):
metadata.load_metadata_file(json_path)
def test_load_folder_metadata_yaml(tmp_path):
data = {'folder': 'metadata'}
metadata_path = tmp_path / 'metadata.yaml'
data = {"folder": "metadata"}
metadata_path = tmp_path / "metadata.yaml"
metadata_path.write_text(yaml.dump(data))
result = metadata.load_folder_metadata(tmp_path)
assert result == data
def test_load_folder_metadata_yml(tmp_path):
data = {'folder': 'metadata_yml'}
metadata_path = tmp_path / 'metadata.yml'
data = {"folder": "metadata_yml"}
metadata_path = tmp_path / "metadata.yml"
metadata_path.write_text(yaml.dump(data))
result = metadata.load_folder_metadata(tmp_path)
assert result == data
def test_load_folder_metadata_json(tmp_path):
data = {'folder': 'metadata_json'}
metadata_path = tmp_path / 'metadata.json'
data = {"folder": "metadata_json"}
metadata_path = tmp_path / "metadata.json"
metadata_path.write_text(json.dumps(data))
result = metadata.load_folder_metadata(tmp_path)
assert result == data
@@ -85,21 +87,21 @@ def test_load_folder_metadata_missing(tmp_path):
def test_get_metadata_file_path_existing_yaml(tmp_path):
metadata_path = tmp_path / 'metadata.yaml'
metadata_path.write_text('test: data')
metadata_path = tmp_path / "metadata.yaml"
metadata_path.write_text("test: data")
result = metadata.get_metadata_file_path(tmp_path)
assert result == str(metadata_path)
def test_get_metadata_file_path_existing_yml(tmp_path):
metadata_path = tmp_path / 'metadata.yml'
metadata_path.write_text('test: data')
metadata_path = tmp_path / "metadata.yml"
metadata_path.write_text("test: data")
result = metadata.get_metadata_file_path(tmp_path)
assert result == str(metadata_path)
def test_get_metadata_file_path_existing_json(tmp_path):
metadata_path = tmp_path / 'metadata.json'
metadata_path = tmp_path / "metadata.json"
metadata_path.write_text('{"test": "data"}')
result = metadata.get_metadata_file_path(tmp_path)
assert result == str(metadata_path)
@@ -107,28 +109,27 @@ def test_get_metadata_file_path_existing_json(tmp_path):
def test_get_metadata_file_path_none_existing(tmp_path):
result = metadata.get_metadata_file_path(tmp_path)
expected = str(tmp_path / 'metadata.yaml')
expected = str(tmp_path / "metadata.yaml")
assert result == expected
def test_merge_metadata():
parent = {'project': 'Test', 'version': '1.0', 'author': 'Parent'}
child = {'experiment': 'A', 'version': '1.1'}
parent = {"project": "Test", "version": "1.0", "author": "Parent"}
child = {"experiment": "A", "version": "1.1"}
merged = metadata.merge_metadata(parent, child)
expected = {'project': 'Test', 'version': '1.1',
'author': 'Parent', 'experiment': 'A'}
expected = {"project": "Test", "version": "1.1", "author": "Parent", "experiment": "A"}
assert merged == expected
def test_merge_metadata_empty_parent():
parent = {}
child = {'experiment': 'A', 'version': '1.1'}
child = {"experiment": "A", "version": "1.1"}
merged = metadata.merge_metadata(parent, child)
assert merged == child
def test_merge_metadata_empty_child():
parent = {'project': 'Test', 'version': '1.0'}
parent = {"project": "Test", "version": "1.0"}
child = {}
merged = metadata.merge_metadata(parent, child)
assert merged == parent
@@ -136,35 +137,34 @@ def test_merge_metadata_empty_child():
def test_resolve_metadata_for_plot_with_specific_yaml(tmp_path):
# Create plot-specific metadata file
plot_path = tmp_path / 'test_plot.pdf'
plot_metadata_path = tmp_path / 'test_plot.yaml'
plot_metadata = {'specific': 'plot_data', 'override': 'plot_value'}
plot_path = tmp_path / "test_plot.pdf"
plot_metadata_path = tmp_path / "test_plot.yaml"
plot_metadata = {"specific": "plot_data", "override": "plot_value"}
plot_metadata_path.write_text(yaml.dump(plot_metadata))
inherited = {'general': 'data', 'override': 'inherited_value'}
inherited = {"general": "data", "override": "inherited_value"}
result = metadata.resolve_metadata_for_plot(plot_path, inherited)
expected = {'general': 'data',
'override': 'plot_value', 'specific': 'plot_data'}
expected = {"general": "data", "override": "plot_value", "specific": "plot_data"}
assert result == expected
def test_resolve_metadata_for_plot_with_specific_json(tmp_path):
plot_path = tmp_path / 'test_plot.pdf'
plot_metadata_path = tmp_path / 'test_plot.json'
plot_metadata = {'specific': 'plot_data_json'}
plot_path = tmp_path / "test_plot.pdf"
plot_metadata_path = tmp_path / "test_plot.json"
plot_metadata = {"specific": "plot_data_json"}
plot_metadata_path.write_text(json.dumps(plot_metadata))
inherited = {'general': 'data'}
inherited = {"general": "data"}
result = metadata.resolve_metadata_for_plot(plot_path, inherited)
expected = {'general': 'data', 'specific': 'plot_data_json'}
expected = {"general": "data", "specific": "plot_data_json"}
assert result == expected
def test_resolve_metadata_for_plot_no_specific(tmp_path):
plot_path = tmp_path / 'test_plot.pdf'
inherited = {'general': 'data', 'inherited': 'value'}
plot_path = tmp_path / "test_plot.pdf"
inherited = {"general": "data", "inherited": "value"}
result = metadata.resolve_metadata_for_plot(plot_path, inherited)
# Should return copy of inherited metadata
@@ -173,17 +173,14 @@ def test_resolve_metadata_for_plot_no_specific(tmp_path):
def test_save_metadata_cache(tmp_path):
cache_data = {
'plot1': {'title': 'Plot 1', 'author': 'Test'},
'plot2': {'title': 'Plot 2', 'experiment': 'B'}
}
cache_data = {"plot1": {"title": "Plot 1", "author": "Test"}, "plot2": {"title": "Plot 2", "experiment": "B"}}
metadata.save_metadata_cache(tmp_path, cache_data)
cache_file = tmp_path / 'meta_cache.json'
cache_file = tmp_path / "meta_cache.json"
assert cache_file.exists()
with cache_file.open('r') as f:
with cache_file.open("r") as f:
loaded_data = json.load(f)
assert loaded_data == cache_data
@@ -193,10 +190,10 @@ def test_save_metadata_cache_empty(tmp_path):
cache_data = {}
metadata.save_metadata_cache(tmp_path, cache_data)
cache_file = tmp_path / 'meta_cache.json'
cache_file = tmp_path / "meta_cache.json"
assert cache_file.exists()
with cache_file.open('r') as f:
with cache_file.open("r") as f:
loaded_data = json.load(f)
assert loaded_data == {}
+330
View File
@@ -0,0 +1,330 @@
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pytest
from matplotlib.colors import Colormap
import plotstyle as ps
from plotstyle import colors
def test_categorical_palette_matches_kit_hex():
assert colors.CATEGORICAL == [
"#009682",
"#DF9B1B",
"#4664AA",
"#A78230",
"#23A1E0",
"#A3107C",
"#8CB63C",
"#A22223",
"#FCE500",
]
def test_diverging_palette_matches_kit_hex():
assert colors.DIVERGING == {"low": "#4664AA", "mid": "#f7f7f7", "high": "#A22223"}
def test_status_palette_matches_validated_hex():
assert colors.STATUS == {
"good": "#0ca30c",
"warning": "#fab219",
"serious": "#ec835a",
"critical": "#d03b3b",
}
def test_get_color_returns_categorical_slot():
assert colors.get_color(0) == colors.CATEGORICAL[0]
assert colors.get_color(len(colors.CATEGORICAL) - 1) == colors.CATEGORICAL[-1]
def test_get_color_out_of_range_raises():
with pytest.raises(ValueError):
colors.get_color(len(colors.CATEGORICAL))
def test_sequential_steps_are_kit_blue_variations():
assert colors.SEQUENTIAL_STEPS[-1].lower() == "#4664aa"
# Lightest step should be a lighter (higher-lightness) tint than the brand color.
import colorsys
def lightness(hex_color):
r, g, b = (int(hex_color.lstrip("#")[i : i + 2], 16) / 255 for i in (0, 2, 4))
return colorsys.rgb_to_hls(r, g, b)[1]
assert lightness(colors.SEQUENTIAL_STEPS[0]) > lightness(colors.SEQUENTIAL_STEPS[-1])
def test_sequential_cmap_is_usable_colormap():
cmap = colors.sequential_cmap()
assert isinstance(cmap, Colormap)
assert cmap(0.0) != cmap(1.0)
def test_diverging_cmap_is_usable_colormap():
cmap = colors.diverging_cmap()
assert isinstance(cmap, Colormap)
assert cmap(0.0) != cmap(1.0)
def test_use_sets_expected_rcparams():
ps.use()
try:
assert plt.rcParams["axes.facecolor"] == "#ffffff"
assert plt.rcParams["text.usetex"] is True
assert "lmodern" in plt.rcParams["text.latex.preamble"]
assert "sfmath" in plt.rcParams["text.latex.preamble"]
assert "fontenc" in plt.rcParams["text.latex.preamble"]
assert r"\sfdefault" in plt.rcParams["text.latex.preamble"]
cycle_colors = [entry["color"] for entry in plt.rcParams["axes.prop_cycle"]]
assert cycle_colors == colors.CATEGORICAL
assert plt.rcParams["axes.spines.top"] is False
assert plt.rcParams["axes.spines.right"] is False
assert plt.rcParams["axes.spines.left"] is False
assert plt.rcParams["axes.spines.bottom"] is True
assert plt.rcParams["xtick.bottom"] is True
assert plt.rcParams["ytick.left"] is True
assert plt.rcParams["axes.edgecolor"] == "#000000"
assert plt.rcParams["axes.linewidth"] > 0.8
assert plt.rcParams["axes.grid"] is True
assert plt.rcParams["axes.grid.axis"] == "y"
assert plt.rcParams["axes.titlelocation"] == "left"
assert plt.rcParams["xtick.minor.visible"] is True
assert plt.rcParams["ytick.minor.visible"] is False
assert plt.rcParams["xtick.major.size"] > plt.rcParams["xtick.minor.size"]
finally:
ps.reset()
def test_use_default_does_not_cycle_linestyles():
ps.use()
try:
cycle_keys = plt.rcParams["axes.prop_cycle"].keys
assert cycle_keys == {"color"}
finally:
ps.reset()
def test_use_cycle_linestyles_opt_in():
ps.use(cycle_linestyles=True)
try:
cycle_keys = plt.rcParams["axes.prop_cycle"].keys
assert cycle_keys == {"color", "linestyle"}
linestyles = {entry["linestyle"] for entry in plt.rcParams["axes.prop_cycle"]}
assert len(linestyles) > 1
finally:
ps.reset()
def test_new_figure_styles_major_and_minor_tick_labels_differently():
fig, ax = ps.new_figure("square")
try:
assert ax.xaxis.get_tick_params(which="major")["labelcolor"] == colors.INK["primary"]
assert ax.xaxis.get_tick_params(which="minor")["labelcolor"] == colors.INK["muted"]
assert ax.yaxis.get_tick_params(which="major")["labelcolor"] == colors.INK["primary"]
assert ax.yaxis.get_tick_params(which="minor")["labelcolor"] == colors.INK["muted"]
finally:
plt.close(fig)
def test_new_figure_preset_sizes():
fig, ax = ps.new_figure("thesis-single")
try:
assert tuple(fig.get_size_inches()) == ps.FIGSIZES["thesis-single"]
finally:
plt.close(fig)
def test_new_figure_explicit_tuple():
fig, ax = ps.new_figure((3.0, 2.0))
try:
assert tuple(fig.get_size_inches()) == (3.0, 2.0)
finally:
plt.close(fig)
def test_new_figure_unknown_preset_raises():
with pytest.raises(ValueError):
ps.new_figure("not-a-real-preset")
def test_new_figure_title_sets_left_aligned_figure_suptitle():
fig, ax = ps.new_figure("square", title="My Title")
try:
assert fig._suptitle is not None
assert "My Title" in fig._suptitle.get_text()
assert fig._suptitle.get_ha() == "left"
assert ax.get_title() == ""
finally:
plt.close(fig)
def test_new_figure_params_adds_subtitle_line():
fig, ax = ps.new_figure("square", title="My Title", params={"N": 100, "seed": 42})
try:
text = fig._suptitle.get_text()
assert "My Title" in text
assert "N: 100" in text
assert "seed: 42" in text
assert "|" in text
finally:
plt.close(fig)
def test_new_figure_without_title_or_params_has_no_suptitle():
fig, ax = ps.new_figure("square")
try:
assert fig._suptitle is None
finally:
plt.close(fig)
def test_colorbar_matches_axes_height():
fig, ax = ps.new_figure("square")
try:
im = ax.imshow([[0, 1], [2, 3]])
cb = ps.colorbar(im, ax)
fig.canvas.draw()
ax_bbox = ax.get_position()
cax_bbox = cb.ax.get_position()
assert ax_bbox.y0 == pytest.approx(cax_bbox.y0, abs=1e-6)
assert ax_bbox.y1 == pytest.approx(cax_bbox.y1, abs=1e-6)
finally:
plt.close(fig)
def test_colorbar_has_no_outline():
fig, ax = ps.new_figure("square")
try:
im = ax.imshow([[0, 1], [2, 3]])
cb = ps.colorbar(im, ax)
assert cb.outline.get_visible() is False
finally:
plt.close(fig)
def test_no_spines_hides_all_spines_on_single_axes():
fig, ax = ps.new_figure("square")
try:
ax.imshow([[0, 1], [2, 3]])
ps.no_spines(ax)
assert all(not spine.get_visible() for spine in ax.spines.values())
finally:
plt.close(fig)
def test_no_spines_hides_all_spines_on_axes_array():
fig, axes = ps.new_figure("square", nrows=1, ncols=2)
try:
ps.no_spines(axes)
for ax in axes:
assert all(not spine.get_visible() for spine in ax.spines.values())
finally:
plt.close(fig)
def test_savefig_writes_requested_formats(tmp_path):
fig, ax = ps.new_figure("square")
ax.plot([0, 1], [0, 1])
try:
out_dir = tmp_path / "nested" / "plots"
written = ps.savefig(fig, out_dir / "my_plot", formats=("pdf", "png"))
finally:
plt.close(fig)
assert [p.name for p in written] == ["my_plot.pdf", "my_plot.png"]
for p in written:
assert p.exists()
assert p.stat().st_size > 0
def test_style_legend_places_legend_on_figure_outside_axes():
fig, ax = ps.new_figure("square")
try:
ax.plot([0, 1], [0, 1], label="series")
legend = ps.style_legend(ax, title="Series")
assert legend in fig.legends
assert ax.get_legend() is None
assert legend.get_title().get_text() == "Series"
finally:
plt.close(fig)
def test_style_legend_without_title_warns():
fig, ax = ps.new_figure("square")
try:
ax.plot([0, 1], [0, 1], label="series")
with pytest.warns(UserWarning):
ps.style_legend(ax)
finally:
plt.close(fig)
def test_panel_label_does_not_raise():
fig, ax = ps.new_figure("square")
try:
ax.plot([0, 1], [0, 1], label="series")
ps.panel_label(ax, "a")
finally:
plt.close(fig)
def test_panel_label_unknown_loc_raises():
fig, ax = ps.new_figure("square")
try:
with pytest.raises(ValueError):
ps.panel_label(ax, "a", loc="middle")
finally:
plt.close(fig)
def test_panel_label_defaults_to_lower_right():
fig, ax = ps.new_figure("square")
try:
text = ps.panel_label(ax, "a")
assert text.get_ha() == "right"
assert text.get_va() == "bottom"
x, y = text.get_position()
assert x > 0.5
assert y < 0.5
finally:
plt.close(fig)
def test_panel_label_color_matches_xlabel():
fig, ax = ps.new_figure("square")
try:
ax.set_xlabel("Time (s)")
text = ps.panel_label(ax, "a")
assert text.get_color() == ax.xaxis.label.get_color()
finally:
plt.close(fig)
def test_panel_label_has_rounded_semi_transparent_box_by_default():
fig, ax = ps.new_figure("square")
try:
text = ps.panel_label(ax, "a")
patch = text.get_bbox_patch()
assert patch is not None
assert "round" in patch.get_boxstyle().__class__.__name__.lower()
assert patch.get_facecolor()[3] < 1.0
assert patch.get_edgecolor()[3] == 1.0
finally:
plt.close(fig)
def test_panel_label_box_can_be_disabled():
fig, ax = ps.new_figure("square")
try:
text = ps.panel_label(ax, "a", box=False)
assert text.get_bbox_patch() is None
finally:
plt.close(fig)
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
Metadata Validation Utility
This script validates metadata files in the gallery source directories,
checking for proper YAML/JSON syntax and common field validation.
"""
import json
import sys
from pathlib import Path
from typing import List
import yaml
def validate_metadata_file(file_path: Path) -> tuple[bool, List[str]]:
"""
Validate a single metadata file.
Args:
file_path: Path to the metadata file
Returns:
Tuple of (is_valid, error_messages)
"""
errors = []
if not file_path.exists():
errors.append(f"File does not exist: {file_path}")
return False, errors
try:
with file_path.open("r", encoding="utf-8") as f:
suffix_lower = file_path.suffix.lower()
if suffix_lower in [".yaml", ".yml"]:
data = yaml.safe_load(f)
elif suffix_lower == ".json":
data = json.load(f)
else:
errors.append(f"Unsupported file format: {file_path}")
return False, errors
if data is None:
errors.append(f"Empty metadata file: {file_path}")
return False, errors
# Basic validation
if not isinstance(data, dict):
errors.append(f"Metadata must be a dictionary: {file_path}")
return False, errors
# Check for common issues
if "title" in data and not isinstance(data["title"], str):
errors.append(f"Title must be a string: {file_path}")
if "tags" in data and not isinstance(data["tags"], list):
errors.append(f"Tags must be a list: {file_path}")
if "author" in data and not isinstance(data["author"], dict):
errors.append(f"Author must be a dictionary: {file_path}")
except (yaml.YAMLError, json.JSONDecodeError) as e:
errors.append(f"Parse error in {file_path}: {e}")
return False, errors
except Exception as e:
errors.append(f"Unexpected error reading {file_path}: {e}")
return False, errors
return len(errors) == 0, errors
def find_metadata_files(root_dir: Path) -> List[Path]:
"""
Find all metadata files in a directory tree.
Args:
root_dir: Root directory to search
Returns:
List of metadata file paths
"""
metadata_files = []
for pattern in ["**/*.yaml", "**/*.yml", "**/*.json"]:
for file_path in root_dir.glob(pattern):
if file_path.name.startswith("meta.") or file_path.stem != file_path.name:
metadata_files.append(file_path)
return metadata_files
def main():
"""Main validation function."""
if len(sys.argv) != 2:
print("Usage: python validate_metadata.py <directory>")
sys.exit(1)
root_dir = Path(sys.argv[1])
if not root_dir.exists():
print(f"Error: Directory does not exist: {root_dir}")
sys.exit(1)
if not root_dir.is_dir():
print(f"Error: Not a directory: {root_dir}")
sys.exit(1)
print(f"Validating metadata files in: {root_dir}")
print("-" * 50)
metadata_files = find_metadata_files(root_dir)
if not metadata_files:
print("No metadata files found.")
return
total_files = len(metadata_files)
valid_files = 0
for file_path in metadata_files:
is_valid, errors = validate_metadata_file(file_path)
if is_valid:
print(f"{file_path.relative_to(root_dir)}")
valid_files += 1
else:
print(f"{file_path.relative_to(root_dir)}")
for error in errors:
print(f" - {error}")
print("-" * 50)
print(f"Summary: {valid_files}/{total_files} files valid")
if valid_files != total_files:
sys.exit(1)
if __name__ == "__main__":
main()
Generated
+2620 -611
View File
File diff suppressed because it is too large Load Diff