Rewrite gallery as an installable package, add plotstyle, migrate build/CI to uv #1
@@ -0,0 +1,14 @@
|
||||
.git
|
||||
.venv
|
||||
*.sif
|
||||
*.ipynb
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache
|
||||
build
|
||||
*.egg-info
|
||||
.claude
|
||||
config.yaml
|
||||
docs/
|
||||
CLAUDE.md
|
||||
@@ -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
|
||||
@@ -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/*
|
||||
@@ -2,6 +2,7 @@
|
||||
.vscode
|
||||
*.sif
|
||||
*.ipynb
|
||||
!examples/*.ipynb
|
||||
backups
|
||||
.pytest_cache
|
||||
.venv
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Executable
+26
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Periodically regenerate the ETPlot gallery
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=5min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -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:
|
||||
@@ -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.*
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,5 +1,4 @@
|
||||
gallery:
|
||||
backup_folder: ''
|
||||
plot_root: gallery
|
||||
png_dpi: 400
|
||||
metadata:
|
||||
|
||||
+16
-19
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"]])
|
||||
@@ -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
|
||||
@@ -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
@@ -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,4 +1,3 @@
|
||||
import sys
|
||||
|
||||
|
||||
sys.path.append("..")
|
||||
|
||||
@@ -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
@@ -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"}]
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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 == {}
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user